ddeutil-workflow 0.0.18__py3-none-any.whl → 0.0.20__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.
@@ -1 +1 @@
1
- __version__: str = "0.0.18"
1
+ __version__: str = "0.0.20"
@@ -646,12 +646,27 @@ class CronJob:
646
646
 
647
647
  :param date: An initial date that want to mark as the start point.
648
648
  :param tz: A string timezone that want to change on runner.
649
+
649
650
  :rtype: CronRunner
650
651
  """
651
652
  return CronRunner(self, date, tz=tz)
652
653
 
653
654
 
654
655
  class CronJobYear(CronJob):
656
+ """The Cron Job Converter with Year extension object that generate datetime
657
+ dimension of cron job schedule format,
658
+
659
+ * * * * * * <command to execute>
660
+
661
+ (i) minute (0 - 59)
662
+ (ii) hour (0 - 23)
663
+ (iii) day of the month (1 - 31)
664
+ (iv) month (1 - 12)
665
+ (v) day of the week (0 - 6) (Sunday to Saturday; 7 is also Sunday
666
+ on some systems)
667
+ (vi) year (1990 - 2100)
668
+ """
669
+
655
670
  cron_length = 6
656
671
  cron_units = CRON_UNITS_YEAR
657
672
 
@@ -704,9 +719,17 @@ class CronRunner:
704
719
  else:
705
720
  self.date: datetime = datetime.now(tz=self.tz)
706
721
 
722
+ # NOTE: Add one second if the microsecond value more than 0.
723
+ if self.date.microsecond > 0:
724
+ self.date: datetime = self.date.replace(microsecond=0) + timedelta(
725
+ seconds=1
726
+ )
727
+
707
728
  # NOTE: Add one minute if the second value more than 0.
708
729
  if self.date.second > 0:
709
- self.date: datetime = self.date + timedelta(minutes=1)
730
+ self.date: datetime = self.date.replace(second=0) + timedelta(
731
+ minutes=1
732
+ )
710
733
 
711
734
  self.__start_date: datetime = self.date
712
735
  self.cron: CronJob | CronJobYear = cron
@@ -752,7 +775,7 @@ class CronRunner:
752
775
  not self.__shift_date(mode, reverse)
753
776
  for mode in ("year", "month", "day", "hour", "minute")
754
777
  ):
755
- return copy.deepcopy(self.date.replace(second=0, microsecond=0))
778
+ return copy.deepcopy(self.date)
756
779
 
757
780
  raise RecursionError("Unable to find execution time for schedule")
758
781
 
@@ -801,6 +824,10 @@ class CronRunner:
801
824
  # NOTE: Replace date that less than it mode to zero.
802
825
  self.date: datetime = replace_date(self.date, mode, reverse=reverse)
803
826
 
827
+ # NOTE: Replace second and microsecond values that change from
828
+ # the replace_date func with reverse flag.
829
+ self.date: datetime = self.date.replace(second=0, microsecond=0)
830
+
804
831
  if current_value != getattr(self.date, switch[mode]):
805
832
  return mode != "month"
806
833
 
@@ -15,7 +15,10 @@ from .exceptions import (
15
15
  UtilException,
16
16
  WorkflowException,
17
17
  )
18
- from .job import Job, Strategy
18
+ from .job import (
19
+ Job,
20
+ Strategy,
21
+ )
19
22
  from .on import (
20
23
  On,
21
24
  YearOn,
@@ -24,8 +27,6 @@ from .on import (
24
27
  from .scheduler import (
25
28
  Schedule,
26
29
  ScheduleWorkflow,
27
- Workflow,
28
- WorkflowTaskData,
29
30
  )
30
31
  from .stage import (
31
32
  BashStage,
@@ -34,7 +35,7 @@ from .stage import (
34
35
  PyStage,
35
36
  Stage,
36
37
  TriggerStage,
37
- handler_result,
38
+ extract_hook,
38
39
  )
39
40
  from .utils import (
40
41
  FILTERS,
@@ -70,3 +71,7 @@ from .utils import (
70
71
  str2template,
71
72
  tag,
72
73
  )
74
+ from .workflow import (
75
+ Workflow,
76
+ WorkflowTaskData,
77
+ )
ddeutil/workflow/conf.py CHANGED
@@ -33,6 +33,29 @@ load_dotenv()
33
33
  env = os.getenv
34
34
 
35
35
 
36
+ @lru_cache
37
+ def get_logger(name: str):
38
+ """Return logger object with an input module name.
39
+
40
+ :param name: A module name that want to log.
41
+ """
42
+ lg = logging.getLogger(name)
43
+ formatter = logging.Formatter(
44
+ fmt=(
45
+ "%(asctime)s.%(msecs)03d (%(name)-10s, %(process)-5d, "
46
+ "%(thread)-5d) [%(levelname)-7s] %(message)-120s "
47
+ "(%(filename)s:%(lineno)s)"
48
+ ),
49
+ datefmt="%Y-%m-%d %H:%M:%S",
50
+ )
51
+ stream = logging.StreamHandler()
52
+ stream.setFormatter(formatter)
53
+ lg.addHandler(stream)
54
+
55
+ lg.setLevel(logging.DEBUG if config.debug else logging.INFO)
56
+ return lg
57
+
58
+
36
59
  class Config:
37
60
  """Config object for keeping application configuration on current session
38
61
  without changing when if the application still running.
@@ -98,12 +121,14 @@ class Config:
98
121
  os.getenv("WORKFLOW_API_ENABLE_ROUTE_SCHEDULE", "true")
99
122
  )
100
123
 
101
- def __init__(self):
124
+ def __init__(self) -> None:
125
+ # VALIDATE: the MAX_JOB_PARALLEL value should not less than 0.
102
126
  if self.max_job_parallel < 0:
103
127
  raise ValueError(
104
128
  f"``MAX_JOB_PARALLEL`` should more than 0 but got "
105
129
  f"{self.max_job_parallel}."
106
130
  )
131
+
107
132
  try:
108
133
  self.stop_boundary_delta: timedelta = timedelta(
109
134
  **json.loads(self.stop_boundary_delta_str)
@@ -195,6 +220,7 @@ class SimLoad:
195
220
  :param conf: A config object.
196
221
  :param include:
197
222
  :param exclude:
223
+
198
224
  :rtype: Iterator[tuple[str, DictData]]
199
225
  """
200
226
  exclude: list[str] = exclude or []
@@ -247,12 +273,14 @@ class Loader(SimLoad):
247
273
  include: list[str] | None = None,
248
274
  exclude: list[str] | None = None,
249
275
  **kwargs,
250
- ) -> DictData:
276
+ ) -> Iterator[tuple[str, DictData]]:
251
277
  """Override the find class method from the Simple Loader object.
252
278
 
253
279
  :param obj: A object that want to validate matching before return.
254
280
  :param include:
255
281
  :param exclude:
282
+
283
+ :rtype: Iterator[tuple[str, DictData]]
256
284
  """
257
285
  return super().finds(
258
286
  obj=obj, conf=Config(), include=include, exclude=exclude
@@ -268,6 +296,7 @@ def get_type(t: str, params: Config) -> AnyModelType:
268
296
  :param t: A importable type string.
269
297
  :param params: A config parameters that use registry to search this
270
298
  type.
299
+
271
300
  :rtype: AnyModelType
272
301
  """
273
302
  try:
@@ -283,29 +312,7 @@ def get_type(t: str, params: Config) -> AnyModelType:
283
312
 
284
313
 
285
314
  config = Config()
286
-
287
-
288
- @lru_cache
289
- def get_logger(name: str):
290
- """Return logger object with an input module name.
291
-
292
- :param name: A module name that want to log.
293
- """
294
- logger = logging.getLogger(name)
295
- formatter = logging.Formatter(
296
- fmt=(
297
- "%(asctime)s.%(msecs)03d (%(name)-10s, %(process)-5d, "
298
- "%(thread)-5d) [%(levelname)-7s] %(message)-120s "
299
- "(%(filename)s:%(lineno)s)"
300
- ),
301
- datefmt="%Y-%m-%d %H:%M:%S",
302
- )
303
- stream = logging.StreamHandler()
304
- stream.setFormatter(formatter)
305
- logger.addHandler(stream)
306
-
307
- logger.setLevel(logging.DEBUG if config.debug else logging.INFO)
308
- return logger
315
+ logger = get_logger("ddeutil.workflow")
309
316
 
310
317
 
311
318
  class BaseLog(BaseModel, ABC):
@@ -315,8 +322,8 @@ class BaseLog(BaseModel, ABC):
315
322
  """
316
323
 
317
324
  name: str = Field(description="A workflow name.")
318
- on: str = Field(description="A cronjob string of this piepline schedule.")
319
325
  release: datetime = Field(description="A release datetime.")
326
+ type: str = Field(description="A running type before logging.")
320
327
  context: DictData = Field(
321
328
  default_factory=dict,
322
329
  description=(
@@ -366,6 +373,8 @@ class FileLog(BaseLog):
366
373
  workflow name.
367
374
 
368
375
  :param name: A workflow name that want to search release logging data.
376
+
377
+ :rtype: Iterator[Self]
369
378
  """
370
379
  pointer: Path = config.root_path / f"./logs/workflow={name}"
371
380
  if not pointer.exists():
@@ -387,6 +396,9 @@ class FileLog(BaseLog):
387
396
  workflow name and release values. If a release does not pass to an input
388
397
  argument, it will return the latest release from the current log path.
389
398
 
399
+ :param name:
400
+ :param release:
401
+
390
402
  :raise FileNotFoundError:
391
403
  :raise NotImplementedError:
392
404
 
@@ -411,21 +423,17 @@ class FileLog(BaseLog):
411
423
  return cls.model_validate(obj=json.load(f))
412
424
 
413
425
  @classmethod
414
- def is_pointed(
415
- cls,
416
- name: str,
417
- release: datetime,
418
- *,
419
- queue: list[datetime] | None = None,
420
- ) -> bool:
421
- """Check this log already point in the destination.
426
+ def is_pointed(cls, name: str, release: datetime) -> bool:
427
+ """Check the release log already pointed or created at the destination
428
+ log path.
422
429
 
423
430
  :param name: A workflow name.
424
431
  :param release: A release datetime.
425
- :param queue: A list of queue of datetime that already run in the
426
- future.
432
+
433
+ :rtype: bool
434
+ :return: Return False if the release log was not pointed or created.
427
435
  """
428
- # NOTE: Check environ variable was set for real writing.
436
+ # NOTE: Return False if enable writing log flag does not set.
429
437
  if not config.enable_write_log:
430
438
  return False
431
439
 
@@ -434,9 +442,7 @@ class FileLog(BaseLog):
434
442
  name=name, release=release
435
443
  )
436
444
 
437
- if not queue:
438
- return pointer.exists()
439
- return pointer.exists() or (release in queue)
445
+ return pointer.exists()
440
446
 
441
447
  def pointer(self) -> Path:
442
448
  """Return release directory path that was generated from model data.
@@ -459,6 +465,9 @@ class FileLog(BaseLog):
459
465
  if not config.enable_write_log:
460
466
  return self
461
467
 
468
+ logger.debug(
469
+ f"({self.run_id}) [LOG]: Start writing log: {self.name!r}."
470
+ )
462
471
  log_file: Path = self.pointer() / f"{self.run_id}.log"
463
472
  log_file.write_text(
464
473
  json.dumps(
@@ -3,6 +3,10 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
+ """Exception objects for this package do not do anything because I want to
7
+ create the lightweight workflow package. So, this module do just a exception
8
+ annotate for handle error only.
9
+ """
6
10
  from __future__ import annotations
7
11
 
8
12
 
ddeutil/workflow/job.py CHANGED
@@ -19,6 +19,7 @@ from concurrent.futures import (
19
19
  as_completed,
20
20
  wait,
21
21
  )
22
+ from enum import Enum
22
23
  from functools import lru_cache
23
24
  from textwrap import dedent
24
25
  from threading import Event
@@ -68,10 +69,14 @@ def make(
68
69
  """Make a list of product of matrix values that already filter with
69
70
  exclude matrix and add specific matrix with include.
70
71
 
72
+ This function use the `lru_cache` decorator function increase
73
+ performance for duplicate matrix value scenario.
74
+
71
75
  :param matrix: A matrix values that want to cross product to possible
72
76
  parallelism values.
73
77
  :param include: A list of additional matrix that want to adds-in.
74
78
  :param exclude: A list of exclude matrix that want to filter-out.
79
+
75
80
  :rtype: list[DictStr]
76
81
  """
77
82
  # NOTE: If it does not set matrix, it will return list of an empty dict.
@@ -198,6 +203,18 @@ class Strategy(BaseModel):
198
203
  return make(self.matrix, self.include, self.exclude)
199
204
 
200
205
 
206
+ class TriggerRules(str, Enum):
207
+ """Trigger Rules enum object."""
208
+
209
+ all_success: str = "all_success"
210
+ all_failed: str = "all_failed"
211
+ all_done: str = "all_done"
212
+ one_failed: str = "one_failed"
213
+ one_success: str = "one_success"
214
+ none_failed: str = "none_failed"
215
+ none_skipped: str = "none_skipped"
216
+
217
+
201
218
  class Job(BaseModel):
202
219
  """Job Pydantic model object (group of stages).
203
220
 
@@ -245,6 +262,11 @@ class Job(BaseModel):
245
262
  default_factory=list,
246
263
  description="A list of Stage of this job.",
247
264
  )
265
+ trigger_rule: TriggerRules = Field(
266
+ default=TriggerRules.all_success,
267
+ description="A trigger rule of tracking needed jobs.",
268
+ serialization_alias="trigger-rule",
269
+ )
248
270
  needs: list[str] = Field(
249
271
  default_factory=list,
250
272
  description="A list of the job ID that want to run before this job.",
@@ -253,12 +275,6 @@ class Job(BaseModel):
253
275
  default_factory=Strategy,
254
276
  description="A strategy matrix that want to generate.",
255
277
  )
256
- run_id: Optional[str] = Field(
257
- default=None,
258
- description="A running job ID.",
259
- repr=False,
260
- exclude=True,
261
- )
262
278
 
263
279
  @model_validator(mode="before")
264
280
  def __prepare_keys__(cls, values: DictData) -> DictData:
@@ -269,6 +285,7 @@ class Job(BaseModel):
269
285
  :rtype: DictData
270
286
  """
271
287
  dash2underscore("runs-on", values)
288
+ dash2underscore("trigger-rule", values)
272
289
  return values
273
290
 
274
291
  @field_validator("desc", mode="after")
@@ -298,29 +315,17 @@ class Job(BaseModel):
298
315
  return value
299
316
 
300
317
  @model_validator(mode="after")
301
- def __prepare_running_id_and_stage_name__(self) -> Self:
302
- """Prepare the job running ID.
318
+ def __validate_job_id__(self) -> Self:
319
+ """Validate job id should not have templating syntax.
303
320
 
304
321
  :rtype: Self
305
322
  """
306
- if self.run_id is None:
307
- self.run_id = gen_id(self.id or "", unique=True)
308
-
309
323
  # VALIDATE: Validate job id should not dynamic with params template.
310
324
  if has_template(self.id):
311
325
  raise ValueError("Job ID should not has any template.")
312
326
 
313
327
  return self
314
328
 
315
- def get_running_id(self, run_id: str) -> Self:
316
- """Return Job model object that changing job running ID with an
317
- input running ID.
318
-
319
- :param run_id: A replace job running ID.
320
- :rtype: Self
321
- """
322
- return self.model_copy(update={"run_id": run_id})
323
-
324
329
  def stage(self, stage_id: str) -> Stage:
325
330
  """Return stage model that match with an input stage ID.
326
331
 
@@ -371,8 +376,6 @@ class Job(BaseModel):
371
376
  # NOTE: If the job ID did not set, it will use index of jobs key
372
377
  # instead.
373
378
  _id: str = self.id or str(len(to["jobs"]) + 1)
374
-
375
- logger.debug(f"({self.run_id}) [JOB]: Set outputs on: {_id}")
376
379
  to["jobs"][_id] = (
377
380
  {"strategies": output}
378
381
  if self.strategy.is_set()
@@ -384,6 +387,7 @@ class Job(BaseModel):
384
387
  self,
385
388
  strategy: DictData,
386
389
  params: DictData,
390
+ run_id: str | None = None,
387
391
  *,
388
392
  event: Event | None = None,
389
393
  ) -> Result:
@@ -399,10 +403,12 @@ class Job(BaseModel):
399
403
 
400
404
  :param strategy: A metrix strategy value.
401
405
  :param params: A dynamic parameters.
406
+ :param run_id: A job running ID for this strategy execution.
402
407
  :param event: An manger event that pass to the PoolThreadExecutor.
403
408
 
404
409
  :rtype: Result
405
410
  """
411
+ run_id: str = run_id or gen_id(self.id or "", unique=True)
406
412
  strategy_id: str = gen_id(strategy)
407
413
 
408
414
  # PARAGRAPH:
@@ -423,22 +429,17 @@ class Job(BaseModel):
423
429
  # IMPORTANT: The stage execution only run sequentially one-by-one.
424
430
  for stage in self.stages:
425
431
 
426
- # IMPORTANT: Change any stage running IDs to this job running ID.
427
- stage: Stage = stage.get_running_id(self.run_id)
428
-
429
- name: str = stage.id or stage.name
430
-
431
432
  if stage.is_skipped(params=context):
432
- logger.info(f"({self.run_id}) [JOB]: Skip stage: {name!r}")
433
+ logger.info(f"({run_id}) [JOB]: Skip stage: {stage.iden!r}")
433
434
  continue
434
435
 
435
436
  logger.info(
436
- f"({self.run_id}) [JOB]: Start execute the stage: {name!r}"
437
+ f"({run_id}) [JOB]: Start execute the stage: {stage.iden!r}"
437
438
  )
438
439
 
439
440
  # NOTE: Logging a matrix that pass on this stage execution.
440
441
  if strategy:
441
- logger.info(f"({self.run_id}) [JOB]: Matrix: {strategy}")
442
+ logger.info(f"({run_id}) [JOB]: Matrix: {strategy}")
442
443
 
443
444
  # NOTE: Force stop this execution if event was set from main
444
445
  # execution.
@@ -463,6 +464,7 @@ class Job(BaseModel):
463
464
  ),
464
465
  },
465
466
  },
467
+ run_id=run_id,
466
468
  )
467
469
 
468
470
  # PARAGRAPH:
@@ -485,12 +487,12 @@ class Job(BaseModel):
485
487
  #
486
488
  try:
487
489
  stage.set_outputs(
488
- stage.execute(params=context).context,
490
+ stage.execute(params=context, run_id=run_id).context,
489
491
  to=context,
490
492
  )
491
493
  except (StageException, UtilException) as err:
492
494
  logger.error(
493
- f"({self.run_id}) [JOB]: {err.__class__.__name__}: {err}"
495
+ f"({run_id}) [JOB]: {err.__class__.__name__}: {err}"
494
496
  )
495
497
  if config.job_raise_error:
496
498
  raise JobException(
@@ -507,10 +509,10 @@ class Job(BaseModel):
507
509
  "error_message": f"{err.__class__.__name__}: {err}",
508
510
  },
509
511
  },
512
+ run_id=run_id,
510
513
  )
511
514
 
512
- # NOTE: Remove the current stage object that was created from
513
- # ``get_running_id`` method for saving memory.
515
+ # NOTE: Remove the current stage object.
514
516
  del stage
515
517
 
516
518
  return Result(
@@ -521,20 +523,23 @@ class Job(BaseModel):
521
523
  "stages": filter_func(context.pop("stages", {})),
522
524
  },
523
525
  },
526
+ run_id=run_id,
524
527
  )
525
528
 
526
- def execute(self, params: DictData | None = None) -> Result:
529
+ def execute(self, params: DictData, run_id: str | None = None) -> Result:
527
530
  """Job execution with passing dynamic parameters from the workflow
528
531
  execution. It will generate matrix values at the first step and run
529
532
  multithread on this metrics to the ``stages`` field of this job.
530
533
 
531
534
  :param params: An input parameters that use on job execution.
535
+ :param run_id: A job running ID for this execution.
536
+
532
537
  :rtype: Result
533
538
  """
534
539
 
535
540
  # NOTE: I use this condition because this method allow passing empty
536
541
  # params and I do not want to create new dict object.
537
- params: DictData = {} if params is None else params
542
+ run_id: str = run_id or gen_id(self.id or "", unique=True)
538
543
  context: DictData = {}
539
544
 
540
545
  # NOTE: Normal Job execution without parallel strategy.
@@ -543,6 +548,7 @@ class Job(BaseModel):
543
548
  rs: Result = self.execute_strategy(
544
549
  strategy=strategy,
545
550
  params=params,
551
+ run_id=run_id,
546
552
  )
547
553
  context.update(rs.context)
548
554
  return Result(
@@ -565,6 +571,7 @@ class Job(BaseModel):
565
571
  self.execute_strategy,
566
572
  strategy=strategy,
567
573
  params=params,
574
+ run_id=run_id,
568
575
  event=event,
569
576
  )
570
577
  for strategy in self.strategy.make()
@@ -572,15 +579,18 @@ class Job(BaseModel):
572
579
 
573
580
  # NOTE: Dynamic catching futures object with fail-fast flag.
574
581
  return (
575
- self.__catch_fail_fast(event=event, futures=futures)
582
+ self.__catch_fail_fast(
583
+ event=event, futures=futures, run_id=run_id
584
+ )
576
585
  if self.strategy.fail_fast
577
- else self.__catch_all_completed(futures=futures)
586
+ else self.__catch_all_completed(futures=futures, run_id=run_id)
578
587
  )
579
588
 
589
+ @staticmethod
580
590
  def __catch_fail_fast(
581
- self,
582
591
  event: Event,
583
592
  futures: list[Future],
593
+ run_id: str,
584
594
  *,
585
595
  timeout: int = 1800,
586
596
  result_timeout: int = 60,
@@ -592,6 +602,7 @@ class Job(BaseModel):
592
602
  :param event: An event manager instance that able to set stopper on the
593
603
  observing thread/process.
594
604
  :param futures: A list of futures.
605
+ :param run_id: A job running ID from execution.
595
606
  :param timeout: A timeout to waiting all futures complete.
596
607
  :param result_timeout: A timeout of getting result from the future
597
608
  instance when it was running completely.
@@ -611,7 +622,7 @@ class Job(BaseModel):
611
622
  nd: str = (
612
623
  f", the strategies do not run is {not_done}" if not_done else ""
613
624
  )
614
- logger.debug(f"({self.run_id}) [JOB]: Strategy is set Fail Fast{nd}")
625
+ logger.debug(f"({run_id}) [JOB]: Strategy is set Fail Fast{nd}")
615
626
 
616
627
  # NOTE:
617
628
  # Stop all running tasks with setting the event manager and cancel
@@ -627,7 +638,7 @@ class Job(BaseModel):
627
638
  if err := future.exception():
628
639
  status: int = 1
629
640
  logger.error(
630
- f"({self.run_id}) [JOB]: One stage failed with: "
641
+ f"({run_id}) [JOB]: One stage failed with: "
631
642
  f"{future.exception()}, shutting down this future."
632
643
  )
633
644
  context.update(
@@ -643,9 +654,10 @@ class Job(BaseModel):
643
654
 
644
655
  return rs_final.catch(status=status, context=context)
645
656
 
657
+ @staticmethod
646
658
  def __catch_all_completed(
647
- self,
648
659
  futures: list[Future],
660
+ run_id: str,
649
661
  *,
650
662
  timeout: int = 1800,
651
663
  result_timeout: int = 60,
@@ -654,6 +666,7 @@ class Job(BaseModel):
654
666
 
655
667
  :param futures: A list of futures that want to catch all completed
656
668
  result.
669
+ :param run_id: A job running ID from execution.
657
670
  :param timeout: A timeout to waiting all futures complete.
658
671
  :param result_timeout: A timeout of getting result from the future
659
672
  instance when it was running completely.
@@ -668,7 +681,7 @@ class Job(BaseModel):
668
681
  except TimeoutError: # pragma: no cov
669
682
  status = 1
670
683
  logger.warning(
671
- f"({self.run_id}) [JOB]: Task is hanging. Attempting to "
684
+ f"({run_id}) [JOB]: Task is hanging. Attempting to "
672
685
  f"kill."
673
686
  )
674
687
  future.cancel()
@@ -679,11 +692,11 @@ class Job(BaseModel):
679
692
  if not future.cancelled()
680
693
  else "Task canceled successfully."
681
694
  )
682
- logger.warning(f"({self.run_id}) [JOB]: {stmt}")
695
+ logger.warning(f"({run_id}) [JOB]: {stmt}")
683
696
  except JobException as err:
684
697
  status = 1
685
698
  logger.error(
686
- f"({self.run_id}) [JOB]: Get stage exception with "
699
+ f"({run_id}) [JOB]: Get stage exception with "
687
700
  f"fail-fast does not set;\n{err.__class__.__name__}:\n\t"
688
701
  f"{err}"
689
702
  )
ddeutil/workflow/on.py CHANGED
@@ -184,11 +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
191
+ runner: CronRunner = self.generate(start=start)
192
+ _ = runner.next
193
+ return runner
192
194
 
193
195
 
194
196
  class YearOn(On):