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/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
- MatrixInclude = list[dict[str, Union[str, int]]]
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: MatrixInclude,
67
- exclude: MatrixExclude,
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 Model that will combine a matrix together for running the
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: MatrixInclude = Field(
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: MatrixExclude = Field(
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 __prepare_running_id_and_stage_name__(self) -> Self:
314
- """Prepare the job running ID.
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 model that match with an input stage ID.
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 strategy value.
413
- :param params: A dynamic parameters.
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"({self.run_id}) [JOB]: Skip stage: {name!r}")
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"({self.run_id}) [JOB]: Matrix: {strategy}")
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
- "Job strategy was canceled from trigger event "
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"({self.run_id}) [JOB]: {err.__class__.__name__}: {err}"
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 that was created from
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
- params: DictData = {} if params is None else params
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=event, futures=futures)
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 exception from all
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 thread/process.
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
- :param result_timeout: A timeout of getting result from the future
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"({self.run_id}) [JOB]: Strategy is set Fail Fast{nd}")
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"({self.run_id}) [JOB]: One stage failed with: "
643
- f"{future.exception()}, shutting down this future."
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(timeout=result_timeout).context)
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 that want to catch all completed
668
- result.
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
- :param result_timeout: A timeout of getting result from the future
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(timeout=result_timeout).context)
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"({self.run_id}) [JOB]: Get stage exception with "
699
- f"fail-fast does not set;\n{err.__class__.__name__}:\n\t"
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) -> 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
- return self.generate(start=start).next
192
-
193
- # def pop(self, queue: list[datetime]) -> datetime:
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):