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/__about__.py +1 -1
- ddeutil/workflow/__cron.py +28 -2
- ddeutil/workflow/__init__.py +9 -4
- ddeutil/workflow/__types.py +1 -0
- ddeutil/workflow/conf.py +34 -25
- ddeutil/workflow/exceptions.py +4 -0
- ddeutil/workflow/job.py +96 -101
- ddeutil/workflow/on.py +4 -15
- ddeutil/workflow/scheduler.py +60 -963
- ddeutil/workflow/stage.py +94 -68
- ddeutil/workflow/utils.py +29 -24
- ddeutil/workflow/workflow.py +1132 -0
- {ddeutil_workflow-0.0.19.dist-info → ddeutil_workflow-0.0.21.dist-info}/METADATA +9 -8
- ddeutil_workflow-0.0.21.dist-info/RECORD +22 -0
- {ddeutil_workflow-0.0.19.dist-info → ddeutil_workflow-0.0.21.dist-info}/WHEEL +1 -1
- ddeutil_workflow-0.0.19.dist-info/RECORD +0 -21
- {ddeutil_workflow-0.0.19.dist-info → ddeutil_workflow-0.0.21.dist-info}/LICENSE +0 -0
- {ddeutil_workflow-0.0.19.dist-info → ddeutil_workflow-0.0.21.dist-info}/entry_points.txt +0 -0
- {ddeutil_workflow-0.0.19.dist-info → ddeutil_workflow-0.0.21.dist-info}/top_level.txt +0 -0
ddeutil/workflow/job.py
CHANGED
@@ -11,7 +11,6 @@ job.
|
|
11
11
|
from __future__ import annotations
|
12
12
|
|
13
13
|
import copy
|
14
|
-
import time
|
15
14
|
from concurrent.futures import (
|
16
15
|
FIRST_EXCEPTION,
|
17
16
|
Future,
|
@@ -48,13 +47,13 @@ from .utils import (
|
|
48
47
|
)
|
49
48
|
|
50
49
|
logger = get_logger("ddeutil.workflow")
|
51
|
-
|
52
|
-
MatrixExclude = list[dict[str, Union[str, int]]]
|
50
|
+
MatrixFilter = list[dict[str, Union[str, int]]]
|
53
51
|
|
54
52
|
|
55
53
|
__all__: TupleStr = (
|
56
54
|
"Strategy",
|
57
55
|
"Job",
|
56
|
+
"TriggerRules",
|
58
57
|
"make",
|
59
58
|
)
|
60
59
|
|
@@ -63,16 +62,20 @@ __all__: TupleStr = (
|
|
63
62
|
@lru_cache
|
64
63
|
def make(
|
65
64
|
matrix: Matrix,
|
66
|
-
include:
|
67
|
-
exclude:
|
65
|
+
include: MatrixFilter,
|
66
|
+
exclude: MatrixFilter,
|
68
67
|
) -> list[DictStr]:
|
69
68
|
"""Make a list of product of matrix values that already filter with
|
70
69
|
exclude matrix and add specific matrix with include.
|
71
70
|
|
71
|
+
This function use the `lru_cache` decorator function increase
|
72
|
+
performance for duplicate matrix value scenario.
|
73
|
+
|
72
74
|
:param matrix: A matrix values that want to cross product to possible
|
73
75
|
parallelism values.
|
74
76
|
:param include: A list of additional matrix that want to adds-in.
|
75
77
|
:param exclude: A list of exclude matrix that want to filter-out.
|
78
|
+
|
76
79
|
:rtype: list[DictStr]
|
77
80
|
"""
|
78
81
|
# NOTE: If it does not set matrix, it will return list of an empty dict.
|
@@ -120,7 +123,7 @@ def make(
|
|
120
123
|
|
121
124
|
|
122
125
|
class Strategy(BaseModel):
|
123
|
-
"""Strategy
|
126
|
+
"""Strategy model that will combine a matrix together for running the
|
124
127
|
special job with combination of matrix data.
|
125
128
|
|
126
129
|
This model does not be the part of job only because you can use it to
|
@@ -162,11 +165,11 @@ class Strategy(BaseModel):
|
|
162
165
|
"A matrix values that want to cross product to possible strategies."
|
163
166
|
),
|
164
167
|
)
|
165
|
-
include:
|
168
|
+
include: MatrixFilter = Field(
|
166
169
|
default_factory=list,
|
167
170
|
description="A list of additional matrix that want to adds-in.",
|
168
171
|
)
|
169
|
-
exclude:
|
172
|
+
exclude: MatrixFilter = Field(
|
170
173
|
default_factory=list,
|
171
174
|
description="A list of exclude matrix that want to filter-out.",
|
172
175
|
)
|
@@ -200,12 +203,26 @@ class Strategy(BaseModel):
|
|
200
203
|
|
201
204
|
|
202
205
|
class TriggerRules(str, Enum):
|
206
|
+
"""Trigger rules enum object."""
|
207
|
+
|
203
208
|
all_success: str = "all_success"
|
204
209
|
all_failed: str = "all_failed"
|
210
|
+
all_done: str = "all_done"
|
211
|
+
one_failed: str = "one_failed"
|
212
|
+
one_success: str = "one_success"
|
213
|
+
none_failed: str = "none_failed"
|
214
|
+
none_skipped: str = "none_skipped"
|
215
|
+
|
216
|
+
|
217
|
+
class RunsOn(str, Enum):
|
218
|
+
"""Runs-On enum object."""
|
219
|
+
|
220
|
+
local: str = "local"
|
221
|
+
docker: str = "docker"
|
205
222
|
|
206
223
|
|
207
224
|
class Job(BaseModel):
|
208
|
-
"""Job Pydantic model object (group of stages).
|
225
|
+
"""Job Pydantic model object (short descripte: a group of stages).
|
209
226
|
|
210
227
|
This job model allow you to use for-loop that call matrix strategy. If
|
211
228
|
you pass matrix mapping and it able to generate, you will see it running
|
@@ -264,12 +281,6 @@ class Job(BaseModel):
|
|
264
281
|
default_factory=Strategy,
|
265
282
|
description="A strategy matrix that want to generate.",
|
266
283
|
)
|
267
|
-
run_id: Optional[str] = Field(
|
268
|
-
default=None,
|
269
|
-
description="A running job ID.",
|
270
|
-
repr=False,
|
271
|
-
exclude=True,
|
272
|
-
)
|
273
284
|
|
274
285
|
@model_validator(mode="before")
|
275
286
|
def __prepare_keys__(cls, values: DictData) -> DictData:
|
@@ -310,31 +321,22 @@ class Job(BaseModel):
|
|
310
321
|
return value
|
311
322
|
|
312
323
|
@model_validator(mode="after")
|
313
|
-
def
|
314
|
-
"""
|
324
|
+
def __validate_job_id__(self) -> Self:
|
325
|
+
"""Validate job id should not have templating syntax.
|
315
326
|
|
316
327
|
:rtype: Self
|
317
328
|
"""
|
318
|
-
if self.run_id is None:
|
319
|
-
self.run_id = gen_id(self.id or "", unique=True)
|
320
|
-
|
321
329
|
# VALIDATE: Validate job id should not dynamic with params template.
|
322
330
|
if has_template(self.id):
|
323
331
|
raise ValueError("Job ID should not has any template.")
|
324
332
|
|
325
333
|
return self
|
326
334
|
|
327
|
-
def get_running_id(self, run_id: str) -> Self:
|
328
|
-
"""Return Job model object that changing job running ID with an
|
329
|
-
input running ID.
|
330
|
-
|
331
|
-
:param run_id: A replace job running ID.
|
332
|
-
:rtype: Self
|
333
|
-
"""
|
334
|
-
return self.model_copy(update={"run_id": run_id})
|
335
|
-
|
336
335
|
def stage(self, stage_id: str) -> Stage:
|
337
|
-
"""Return stage
|
336
|
+
"""Return stage instance that exists in this job via passing an input
|
337
|
+
stage ID.
|
338
|
+
|
339
|
+
:raise ValueError: If an input stage ID does not found on this job.
|
338
340
|
|
339
341
|
:param stage_id: A stage ID that want to extract from this job.
|
340
342
|
:rtype: Stage
|
@@ -367,8 +369,12 @@ class Job(BaseModel):
|
|
367
369
|
}
|
368
370
|
}
|
369
371
|
|
372
|
+
:raise JobException: If the job's ID does not set and the setting
|
373
|
+
default job ID flag does not set.
|
374
|
+
|
370
375
|
:param output: An output context.
|
371
376
|
:param to: A context data that want to add output result.
|
377
|
+
|
372
378
|
:rtype: DictData
|
373
379
|
"""
|
374
380
|
if self.id is None and not config.job_default_id:
|
@@ -383,8 +389,6 @@ class Job(BaseModel):
|
|
383
389
|
# NOTE: If the job ID did not set, it will use index of jobs key
|
384
390
|
# instead.
|
385
391
|
_id: str = self.id or str(len(to["jobs"]) + 1)
|
386
|
-
|
387
|
-
logger.debug(f"({self.run_id}) [JOB]: Set outputs on: {_id}")
|
388
392
|
to["jobs"][_id] = (
|
389
393
|
{"strategies": output}
|
390
394
|
if self.strategy.is_set()
|
@@ -397,6 +401,7 @@ class Job(BaseModel):
|
|
397
401
|
strategy: DictData,
|
398
402
|
params: DictData,
|
399
403
|
*,
|
404
|
+
run_id: str | None = None,
|
400
405
|
event: Event | None = None,
|
401
406
|
) -> Result:
|
402
407
|
"""Job Strategy execution with passing dynamic parameters from the
|
@@ -406,15 +411,21 @@ class Job(BaseModel):
|
|
406
411
|
It different with ``self.execute`` because this method run only one
|
407
412
|
strategy and return with context of this strategy data.
|
408
413
|
|
414
|
+
The result of this execution will return result with strategy ID
|
415
|
+
that generated from the `gen_id` function with a input strategy value.
|
416
|
+
|
409
417
|
:raise JobException: If it has any error from ``StageException`` or
|
410
418
|
``UtilException``.
|
411
419
|
|
412
|
-
:param strategy: A metrix
|
413
|
-
|
420
|
+
:param strategy: A strategy metrix value that use on this execution.
|
421
|
+
This value will pass to the `matrix` key for templating.
|
422
|
+
:param params: A dynamic parameters that will deepcopy to the context.
|
423
|
+
:param run_id: A job running ID for this strategy execution.
|
414
424
|
:param event: An manger event that pass to the PoolThreadExecutor.
|
415
425
|
|
416
426
|
:rtype: Result
|
417
427
|
"""
|
428
|
+
run_id: str = run_id or gen_id(self.id or "", unique=True)
|
418
429
|
strategy_id: str = gen_id(strategy)
|
419
430
|
|
420
431
|
# PARAGRAPH:
|
@@ -435,26 +446,23 @@ class Job(BaseModel):
|
|
435
446
|
# IMPORTANT: The stage execution only run sequentially one-by-one.
|
436
447
|
for stage in self.stages:
|
437
448
|
|
438
|
-
# IMPORTANT: Change any stage running IDs to this job running ID.
|
439
|
-
stage: Stage = stage.get_running_id(self.run_id)
|
440
|
-
|
441
|
-
name: str = stage.id or stage.name
|
442
|
-
|
443
449
|
if stage.is_skipped(params=context):
|
444
|
-
logger.info(f"({
|
450
|
+
logger.info(f"({run_id}) [JOB]: Skip stage: {stage.iden!r}")
|
445
451
|
continue
|
446
452
|
|
447
|
-
logger.info(
|
448
|
-
f"({self.run_id}) [JOB]: Start execute the stage: {name!r}"
|
449
|
-
)
|
453
|
+
logger.info(f"({run_id}) [JOB]: Execute stage: {stage.iden!r}")
|
450
454
|
|
451
455
|
# NOTE: Logging a matrix that pass on this stage execution.
|
452
456
|
if strategy:
|
453
|
-
logger.info(f"({
|
457
|
+
logger.info(f"({run_id}) [JOB]: ... Matrix: {strategy}")
|
454
458
|
|
455
459
|
# NOTE: Force stop this execution if event was set from main
|
456
460
|
# execution.
|
457
461
|
if event and event.is_set():
|
462
|
+
error_msg: str = (
|
463
|
+
"Job strategy was canceled from event that had set before "
|
464
|
+
"strategy execution."
|
465
|
+
)
|
458
466
|
return Result(
|
459
467
|
status=1,
|
460
468
|
context={
|
@@ -464,17 +472,13 @@ class Job(BaseModel):
|
|
464
472
|
# it will not filter function object from context.
|
465
473
|
# ---
|
466
474
|
# "stages": filter_func(context.pop("stages", {})),
|
475
|
+
#
|
467
476
|
"stages": context.pop("stages", {}),
|
468
|
-
"error": JobException(
|
469
|
-
|
470
|
-
"that had stopped before execution."
|
471
|
-
),
|
472
|
-
"error_message": (
|
473
|
-
"Job strategy was canceled from trigger event "
|
474
|
-
"that had stopped before execution."
|
475
|
-
),
|
477
|
+
"error": JobException(error_msg),
|
478
|
+
"error_message": error_msg,
|
476
479
|
},
|
477
480
|
},
|
481
|
+
run_id=run_id,
|
478
482
|
)
|
479
483
|
|
480
484
|
# PARAGRAPH:
|
@@ -497,12 +501,12 @@ class Job(BaseModel):
|
|
497
501
|
#
|
498
502
|
try:
|
499
503
|
stage.set_outputs(
|
500
|
-
stage.execute(params=context).context,
|
504
|
+
stage.execute(params=context, run_id=run_id).context,
|
501
505
|
to=context,
|
502
506
|
)
|
503
507
|
except (StageException, UtilException) as err:
|
504
508
|
logger.error(
|
505
|
-
f"({
|
509
|
+
f"({run_id}) [JOB]: {err.__class__.__name__}: {err}"
|
506
510
|
)
|
507
511
|
if config.job_raise_error:
|
508
512
|
raise JobException(
|
@@ -519,10 +523,10 @@ class Job(BaseModel):
|
|
519
523
|
"error_message": f"{err.__class__.__name__}: {err}",
|
520
524
|
},
|
521
525
|
},
|
526
|
+
run_id=run_id,
|
522
527
|
)
|
523
528
|
|
524
|
-
# NOTE: Remove the current stage object
|
525
|
-
# ``get_running_id`` method for saving memory.
|
529
|
+
# NOTE: Remove the current stage object for saving memory.
|
526
530
|
del stage
|
527
531
|
|
528
532
|
return Result(
|
@@ -533,28 +537,33 @@ class Job(BaseModel):
|
|
533
537
|
"stages": filter_func(context.pop("stages", {})),
|
534
538
|
},
|
535
539
|
},
|
540
|
+
run_id=run_id,
|
536
541
|
)
|
537
542
|
|
538
|
-
def execute(self, params: DictData | None = None) -> Result:
|
543
|
+
def execute(self, params: DictData, run_id: str | None = None) -> Result:
|
539
544
|
"""Job execution with passing dynamic parameters from the workflow
|
540
545
|
execution. It will generate matrix values at the first step and run
|
541
546
|
multithread on this metrics to the ``stages`` field of this job.
|
542
547
|
|
543
548
|
:param params: An input parameters that use on job execution.
|
549
|
+
:param run_id: A job running ID for this execution.
|
550
|
+
|
544
551
|
:rtype: Result
|
545
552
|
"""
|
546
553
|
|
547
554
|
# NOTE: I use this condition because this method allow passing empty
|
548
555
|
# params and I do not want to create new dict object.
|
549
|
-
|
556
|
+
run_id: str = run_id or gen_id(self.id or "", unique=True)
|
550
557
|
context: DictData = {}
|
551
558
|
|
552
|
-
# NOTE: Normal Job execution without parallel strategy.
|
559
|
+
# NOTE: Normal Job execution without parallel strategy matrix. It use
|
560
|
+
# for-loop to control strategy execution sequentially.
|
553
561
|
if (not self.strategy.is_set()) or self.strategy.max_parallel == 1:
|
554
562
|
for strategy in self.strategy.make():
|
555
563
|
rs: Result = self.execute_strategy(
|
556
564
|
strategy=strategy,
|
557
565
|
params=params,
|
566
|
+
run_id=run_id,
|
558
567
|
)
|
559
568
|
context.update(rs.context)
|
560
569
|
return Result(
|
@@ -572,41 +581,42 @@ class Job(BaseModel):
|
|
572
581
|
max_workers=self.strategy.max_parallel,
|
573
582
|
thread_name_prefix="job_strategy_exec_",
|
574
583
|
) as executor:
|
584
|
+
|
575
585
|
futures: list[Future] = [
|
576
586
|
executor.submit(
|
577
587
|
self.execute_strategy,
|
578
588
|
strategy=strategy,
|
579
589
|
params=params,
|
590
|
+
run_id=run_id,
|
580
591
|
event=event,
|
581
592
|
)
|
582
593
|
for strategy in self.strategy.make()
|
583
594
|
]
|
584
595
|
|
585
|
-
# NOTE: Dynamic catching futures object with fail-fast flag.
|
586
596
|
return (
|
587
|
-
self.__catch_fail_fast(event
|
597
|
+
self.__catch_fail_fast(event, futures=futures, run_id=run_id)
|
588
598
|
if self.strategy.fail_fast
|
589
|
-
else self.__catch_all_completed(futures=futures)
|
599
|
+
else self.__catch_all_completed(futures=futures, run_id=run_id)
|
590
600
|
)
|
591
601
|
|
602
|
+
@staticmethod
|
592
603
|
def __catch_fail_fast(
|
593
|
-
self,
|
594
604
|
event: Event,
|
595
605
|
futures: list[Future],
|
606
|
+
run_id: str,
|
596
607
|
*,
|
597
608
|
timeout: int = 1800,
|
598
|
-
result_timeout: int = 60,
|
599
609
|
) -> Result:
|
600
610
|
"""Job parallel pool futures catching with fail-fast mode. That will
|
601
|
-
stop all not done futures if it receive the first
|
602
|
-
running futures.
|
611
|
+
stop and set event on all not done futures if it receive the first
|
612
|
+
exception from all running futures.
|
603
613
|
|
604
614
|
:param event: An event manager instance that able to set stopper on the
|
605
|
-
observing
|
615
|
+
observing multithreading.
|
606
616
|
:param futures: A list of futures.
|
617
|
+
:param run_id: A job running ID from execution.
|
607
618
|
:param timeout: A timeout to waiting all futures complete.
|
608
|
-
|
609
|
-
instance when it was running completely.
|
619
|
+
|
610
620
|
:rtype: Result
|
611
621
|
"""
|
612
622
|
rs_final: Result = Result()
|
@@ -616,14 +626,12 @@ class Job(BaseModel):
|
|
616
626
|
# NOTE: Get results from a collection of tasks with a timeout that has
|
617
627
|
# the first exception.
|
618
628
|
done, not_done = wait(
|
619
|
-
futures,
|
620
|
-
timeout=timeout,
|
621
|
-
return_when=FIRST_EXCEPTION,
|
629
|
+
futures, timeout=timeout, return_when=FIRST_EXCEPTION
|
622
630
|
)
|
623
631
|
nd: str = (
|
624
632
|
f", the strategies do not run is {not_done}" if not_done else ""
|
625
633
|
)
|
626
|
-
logger.debug(f"({
|
634
|
+
logger.debug(f"({run_id}) [JOB]: Strategy is set Fail Fast{nd}")
|
627
635
|
|
628
636
|
# NOTE:
|
629
637
|
# Stop all running tasks with setting the event manager and cancel
|
@@ -636,11 +644,13 @@ class Job(BaseModel):
|
|
636
644
|
|
637
645
|
future: Future
|
638
646
|
for future in done:
|
647
|
+
|
648
|
+
# NOTE: Handle the first exception from feature
|
639
649
|
if err := future.exception():
|
640
650
|
status: int = 1
|
641
651
|
logger.error(
|
642
|
-
f"({
|
643
|
-
f"{future.exception()}
|
652
|
+
f"({run_id}) [JOB]: Fail-fast catching:\n\t"
|
653
|
+
f"{future.exception()}"
|
644
654
|
)
|
645
655
|
context.update(
|
646
656
|
{
|
@@ -651,53 +661,37 @@ class Job(BaseModel):
|
|
651
661
|
continue
|
652
662
|
|
653
663
|
# NOTE: Update the result context to main job context.
|
654
|
-
context.update(future.result(
|
664
|
+
context.update(future.result().context)
|
655
665
|
|
656
666
|
return rs_final.catch(status=status, context=context)
|
657
667
|
|
668
|
+
@staticmethod
|
658
669
|
def __catch_all_completed(
|
659
|
-
self,
|
660
670
|
futures: list[Future],
|
671
|
+
run_id: str,
|
661
672
|
*,
|
662
673
|
timeout: int = 1800,
|
663
|
-
result_timeout: int = 60,
|
664
674
|
) -> Result:
|
665
675
|
"""Job parallel pool futures catching with all-completed mode.
|
666
676
|
|
667
|
-
:param futures: A list of futures
|
668
|
-
|
677
|
+
:param futures: A list of futures.
|
678
|
+
:param run_id: A job running ID from execution.
|
669
679
|
:param timeout: A timeout to waiting all futures complete.
|
670
|
-
|
671
|
-
instance when it was running completely.
|
680
|
+
|
672
681
|
:rtype: Result
|
673
682
|
"""
|
674
683
|
rs_final: Result = Result()
|
675
684
|
context: DictData = {}
|
676
685
|
status: int = 0
|
686
|
+
|
677
687
|
for future in as_completed(futures, timeout=timeout):
|
678
688
|
try:
|
679
|
-
context.update(future.result(
|
680
|
-
except TimeoutError: # pragma: no cov
|
681
|
-
status = 1
|
682
|
-
logger.warning(
|
683
|
-
f"({self.run_id}) [JOB]: Task is hanging. Attempting to "
|
684
|
-
f"kill."
|
685
|
-
)
|
686
|
-
future.cancel()
|
687
|
-
time.sleep(0.1)
|
688
|
-
|
689
|
-
stmt: str = (
|
690
|
-
"Failed to cancel the task."
|
691
|
-
if not future.cancelled()
|
692
|
-
else "Task canceled successfully."
|
693
|
-
)
|
694
|
-
logger.warning(f"({self.run_id}) [JOB]: {stmt}")
|
689
|
+
context.update(future.result().context)
|
695
690
|
except JobException as err:
|
696
691
|
status = 1
|
697
692
|
logger.error(
|
698
|
-
f"({
|
699
|
-
f"
|
700
|
-
f"{err}"
|
693
|
+
f"({run_id}) [JOB]: All-completed catching:\n\t"
|
694
|
+
f"{err.__class__.__name__}:\n\t{err}"
|
701
695
|
)
|
702
696
|
context.update(
|
703
697
|
{
|
@@ -705,4 +699,5 @@ class Job(BaseModel):
|
|
705
699
|
"error_message": f"{err.__class__.__name__}: {err}",
|
706
700
|
},
|
707
701
|
)
|
702
|
+
|
708
703
|
return rs_final.catch(status=status, context=context)
|
ddeutil/workflow/on.py
CHANGED
@@ -184,24 +184,13 @@ class On(BaseModel):
|
|
184
184
|
raise TypeError("start value should be str or datetime type.")
|
185
185
|
return self.cronjob.schedule(date=start, tz=self.tz)
|
186
186
|
|
187
|
-
def next(self, start: str | datetime) ->
|
187
|
+
def next(self, start: str | datetime) -> CronRunner:
|
188
188
|
"""Return a next datetime from Cron runner object that start with any
|
189
189
|
date that given from input.
|
190
190
|
"""
|
191
|
-
|
192
|
-
|
193
|
-
|
194
|
-
# """Pop the matching datetime value from list of datetime alias queue."""
|
195
|
-
# for dt in queue:
|
196
|
-
# if self.next(dt) == dt:
|
197
|
-
# return dt
|
198
|
-
#
|
199
|
-
# # NOTE: Add 1 second value to the current datetime for forcing crontab
|
200
|
-
# # runner generate the next datetime instead if current datetime be
|
201
|
-
# # valid because I already replaced second to zero before passing.
|
202
|
-
# return datetime.now(tz=config.tz).replace(
|
203
|
-
# second=0, microsecond=0
|
204
|
-
# ) + timedelta(seconds=1)
|
191
|
+
runner: CronRunner = self.generate(start=start)
|
192
|
+
_ = runner.next
|
193
|
+
return runner
|
205
194
|
|
206
195
|
|
207
196
|
class YearOn(On):
|