ddeutil-workflow 0.0.19__tar.gz → 0.0.20__tar.gz

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.
Files changed (59) hide show
  1. {ddeutil_workflow-0.0.19/src/ddeutil_workflow.egg-info → ddeutil_workflow-0.0.20}/PKG-INFO +3 -3
  2. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/pyproject.toml +2 -2
  3. ddeutil_workflow-0.0.20/src/ddeutil/workflow/__about__.py +1 -0
  4. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/__cron.py +28 -2
  5. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/__init__.py +9 -4
  6. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/conf.py +31 -25
  7. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/exceptions.py +4 -0
  8. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/job.py +46 -45
  9. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/on.py +4 -15
  10. ddeutil_workflow-0.0.20/src/ddeutil/workflow/scheduler.py +574 -0
  11. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/stage.py +92 -66
  12. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/utils.py +29 -24
  13. ddeutil_workflow-0.0.20/src/ddeutil/workflow/workflow.py +1084 -0
  14. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20/src/ddeutil_workflow.egg-info}/PKG-INFO +3 -3
  15. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil_workflow.egg-info/SOURCES.txt +12 -10
  16. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil_workflow.egg-info/requires.txt +2 -2
  17. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test__cron.py +1 -2
  18. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_conf_log.py +3 -3
  19. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_job.py +2 -7
  20. ddeutil_workflow-0.0.19/tests/test_job_strategy_run.py → ddeutil_workflow-0.0.20/tests/test_job_exec_strategy.py +1 -1
  21. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_on.py +6 -0
  22. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_scheduler.py +26 -18
  23. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_scheduler_tasks.py +20 -32
  24. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_stage.py +11 -10
  25. ddeutil_workflow-0.0.19/tests/test_stage_bash.py → ddeutil_workflow-0.0.20/tests/test_stage_exec_bash.py +3 -3
  26. ddeutil_workflow-0.0.19/tests/test_stage_hook.py → ddeutil_workflow-0.0.20/tests/test_stage_exec_hook.py +5 -5
  27. ddeutil_workflow-0.0.19/tests/test_stage_py.py → ddeutil_workflow-0.0.20/tests/test_stage_exec_py.py +4 -4
  28. ddeutil_workflow-0.0.19/tests/test_stage_trigger.py → ddeutil_workflow-0.0.20/tests/test_stage_exec_trigger.py +2 -2
  29. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_utils_result.py +1 -1
  30. ddeutil_workflow-0.0.19/tests/test_workflow_run.py → ddeutil_workflow-0.0.20/tests/test_workflow_exec.py +5 -5
  31. ddeutil_workflow-0.0.19/tests/test_workflow_task.py → ddeutil_workflow-0.0.20/tests/test_workflow_exec_hook.py +4 -4
  32. ddeutil_workflow-0.0.19/tests/test_workflow_depends.py → ddeutil_workflow-0.0.20/tests/test_workflow_exec_needs.py +2 -2
  33. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_workflow_poke.py +17 -23
  34. ddeutil_workflow-0.0.20/tests/test_workflow_release.py +44 -0
  35. ddeutil_workflow-0.0.19/src/ddeutil/workflow/__about__.py +0 -1
  36. ddeutil_workflow-0.0.19/src/ddeutil/workflow/scheduler.py +0 -1477
  37. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/LICENSE +0 -0
  38. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/README.md +0 -0
  39. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/setup.cfg +0 -0
  40. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/__types.py +0 -0
  41. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/api.py +0 -0
  42. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/cli.py +0 -0
  43. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/repeat.py +0 -0
  44. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil/workflow/route.py +0 -0
  45. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil_workflow.egg-info/dependency_links.txt +0 -0
  46. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil_workflow.egg-info/entry_points.txt +0 -0
  47. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/src/ddeutil_workflow.egg-info/top_level.txt +0 -0
  48. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test__regex.py +0 -0
  49. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_conf.py +0 -0
  50. /ddeutil_workflow-0.0.19/tests/test_job_py.py → /ddeutil_workflow-0.0.20/tests/test_job_exec_py.py +0 -0
  51. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_job_strategy.py +0 -0
  52. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_params.py +0 -0
  53. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_utils.py +0 -0
  54. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_utils_filter.py +0 -0
  55. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_utils_params.py +0 -0
  56. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_utils_tag.py +0 -0
  57. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_utils_template.py +0 -0
  58. {ddeutil_workflow-0.0.19 → ddeutil_workflow-0.0.20}/tests/test_workflow.py +0 -0
  59. /ddeutil_workflow-0.0.19/tests/test_workflow_job_run.py → /ddeutil_workflow-0.0.20/tests/test_workflow_job_exec.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ddeutil-workflow
3
- Version: 0.0.19
3
+ Version: 0.0.20
4
4
  Summary: Lightweight workflow orchestration with less dependencies
5
5
  Author-email: ddeutils <korawich.anu@gmail.com>
6
6
  License: MIT
@@ -24,9 +24,9 @@ Description-Content-Type: text/markdown
24
24
  License-File: LICENSE
25
25
  Requires-Dist: ddeutil>=0.4.3
26
26
  Requires-Dist: ddeutil-io[toml,yaml]>=0.2.3
27
- Requires-Dist: pydantic==2.9.2
27
+ Requires-Dist: pydantic==2.10.2
28
28
  Requires-Dist: python-dotenv==1.0.1
29
- Requires-Dist: typer<1.0.0,==0.12.5
29
+ Requires-Dist: typer==0.15.1
30
30
  Requires-Dist: schedule<2.0.0,==1.2.2
31
31
  Provides-Extra: api
32
32
  Requires-Dist: fastapi<1.0.0,>=0.115.0; extra == "api"
@@ -28,9 +28,9 @@ requires-python = ">=3.9.13"
28
28
  dependencies = [
29
29
  "ddeutil>=0.4.3",
30
30
  "ddeutil-io[yaml,toml]>=0.2.3",
31
- "pydantic==2.9.2",
31
+ "pydantic==2.10.2",
32
32
  "python-dotenv==1.0.1",
33
- "typer==0.12.5,<1.0.0",
33
+ "typer==0.15.1",
34
34
  "schedule==1.2.2,<2.0.0",
35
35
  ]
36
36
  dynamic = ["version"]
@@ -0,0 +1 @@
1
+ __version__: str = "0.0.20"
@@ -653,6 +653,20 @@ class CronJob:
653
653
 
654
654
 
655
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
+
656
670
  cron_length = 6
657
671
  cron_units = CRON_UNITS_YEAR
658
672
 
@@ -705,9 +719,17 @@ class CronRunner:
705
719
  else:
706
720
  self.date: datetime = datetime.now(tz=self.tz)
707
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
+
708
728
  # NOTE: Add one minute if the second value more than 0.
709
729
  if self.date.second > 0:
710
- self.date: datetime = self.date + timedelta(minutes=1)
730
+ self.date: datetime = self.date.replace(second=0) + timedelta(
731
+ minutes=1
732
+ )
711
733
 
712
734
  self.__start_date: datetime = self.date
713
735
  self.cron: CronJob | CronJobYear = cron
@@ -753,7 +775,7 @@ class CronRunner:
753
775
  not self.__shift_date(mode, reverse)
754
776
  for mode in ("year", "month", "day", "hour", "minute")
755
777
  ):
756
- return copy.deepcopy(self.date.replace(second=0, microsecond=0))
778
+ return copy.deepcopy(self.date)
757
779
 
758
780
  raise RecursionError("Unable to find execution time for schedule")
759
781
 
@@ -802,6 +824,10 @@ class CronRunner:
802
824
  # NOTE: Replace date that less than it mode to zero.
803
825
  self.date: datetime = replace_date(self.date, mode, reverse=reverse)
804
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
+
805
831
  if current_value != getattr(self.date, switch[mode]):
806
832
  return mode != "month"
807
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
+ )
@@ -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)
@@ -287,29 +312,7 @@ def get_type(t: str, params: Config) -> AnyModelType:
287
312
 
288
313
 
289
314
  config = Config()
290
-
291
-
292
- @lru_cache
293
- def get_logger(name: str):
294
- """Return logger object with an input module name.
295
-
296
- :param name: A module name that want to log.
297
- """
298
- logger = logging.getLogger(name)
299
- formatter = logging.Formatter(
300
- fmt=(
301
- "%(asctime)s.%(msecs)03d (%(name)-10s, %(process)-5d, "
302
- "%(thread)-5d) [%(levelname)-7s] %(message)-120s "
303
- "(%(filename)s:%(lineno)s)"
304
- ),
305
- datefmt="%Y-%m-%d %H:%M:%S",
306
- )
307
- stream = logging.StreamHandler()
308
- stream.setFormatter(formatter)
309
- logger.addHandler(stream)
310
-
311
- logger.setLevel(logging.DEBUG if config.debug else logging.INFO)
312
- return logger
315
+ logger = get_logger("ddeutil.workflow")
313
316
 
314
317
 
315
318
  class BaseLog(BaseModel, ABC):
@@ -319,8 +322,8 @@ class BaseLog(BaseModel, ABC):
319
322
  """
320
323
 
321
324
  name: str = Field(description="A workflow name.")
322
- on: str = Field(description="A cronjob string of this piepline schedule.")
323
325
  release: datetime = Field(description="A release datetime.")
326
+ type: str = Field(description="A running type before logging.")
324
327
  context: DictData = Field(
325
328
  default_factory=dict,
326
329
  description=(
@@ -462,6 +465,9 @@ class FileLog(BaseLog):
462
465
  if not config.enable_write_log:
463
466
  return self
464
467
 
468
+ logger.debug(
469
+ f"({self.run_id}) [LOG]: Start writing log: {self.name!r}."
470
+ )
465
471
  log_file: Path = self.pointer() / f"{self.run_id}.log"
466
472
  log_file.write_text(
467
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
 
@@ -69,10 +69,14 @@ def make(
69
69
  """Make a list of product of matrix values that already filter with
70
70
  exclude matrix and add specific matrix with include.
71
71
 
72
+ This function use the `lru_cache` decorator function increase
73
+ performance for duplicate matrix value scenario.
74
+
72
75
  :param matrix: A matrix values that want to cross product to possible
73
76
  parallelism values.
74
77
  :param include: A list of additional matrix that want to adds-in.
75
78
  :param exclude: A list of exclude matrix that want to filter-out.
79
+
76
80
  :rtype: list[DictStr]
77
81
  """
78
82
  # NOTE: If it does not set matrix, it will return list of an empty dict.
@@ -200,8 +204,15 @@ class Strategy(BaseModel):
200
204
 
201
205
 
202
206
  class TriggerRules(str, Enum):
207
+ """Trigger Rules enum object."""
208
+
203
209
  all_success: str = "all_success"
204
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"
205
216
 
206
217
 
207
218
  class Job(BaseModel):
@@ -264,12 +275,6 @@ class Job(BaseModel):
264
275
  default_factory=Strategy,
265
276
  description="A strategy matrix that want to generate.",
266
277
  )
267
- run_id: Optional[str] = Field(
268
- default=None,
269
- description="A running job ID.",
270
- repr=False,
271
- exclude=True,
272
- )
273
278
 
274
279
  @model_validator(mode="before")
275
280
  def __prepare_keys__(cls, values: DictData) -> DictData:
@@ -310,29 +315,17 @@ class Job(BaseModel):
310
315
  return value
311
316
 
312
317
  @model_validator(mode="after")
313
- def __prepare_running_id_and_stage_name__(self) -> Self:
314
- """Prepare the job running ID.
318
+ def __validate_job_id__(self) -> Self:
319
+ """Validate job id should not have templating syntax.
315
320
 
316
321
  :rtype: Self
317
322
  """
318
- if self.run_id is None:
319
- self.run_id = gen_id(self.id or "", unique=True)
320
-
321
323
  # VALIDATE: Validate job id should not dynamic with params template.
322
324
  if has_template(self.id):
323
325
  raise ValueError("Job ID should not has any template.")
324
326
 
325
327
  return self
326
328
 
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
329
  def stage(self, stage_id: str) -> Stage:
337
330
  """Return stage model that match with an input stage ID.
338
331
 
@@ -383,8 +376,6 @@ class Job(BaseModel):
383
376
  # NOTE: If the job ID did not set, it will use index of jobs key
384
377
  # instead.
385
378
  _id: str = self.id or str(len(to["jobs"]) + 1)
386
-
387
- logger.debug(f"({self.run_id}) [JOB]: Set outputs on: {_id}")
388
379
  to["jobs"][_id] = (
389
380
  {"strategies": output}
390
381
  if self.strategy.is_set()
@@ -396,6 +387,7 @@ class Job(BaseModel):
396
387
  self,
397
388
  strategy: DictData,
398
389
  params: DictData,
390
+ run_id: str | None = None,
399
391
  *,
400
392
  event: Event | None = None,
401
393
  ) -> Result:
@@ -411,10 +403,12 @@ class Job(BaseModel):
411
403
 
412
404
  :param strategy: A metrix strategy value.
413
405
  :param params: A dynamic parameters.
406
+ :param run_id: A job running ID for this strategy execution.
414
407
  :param event: An manger event that pass to the PoolThreadExecutor.
415
408
 
416
409
  :rtype: Result
417
410
  """
411
+ run_id: str = run_id or gen_id(self.id or "", unique=True)
418
412
  strategy_id: str = gen_id(strategy)
419
413
 
420
414
  # PARAGRAPH:
@@ -435,22 +429,17 @@ class Job(BaseModel):
435
429
  # IMPORTANT: The stage execution only run sequentially one-by-one.
436
430
  for stage in self.stages:
437
431
 
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
432
  if stage.is_skipped(params=context):
444
- logger.info(f"({self.run_id}) [JOB]: Skip stage: {name!r}")
433
+ logger.info(f"({run_id}) [JOB]: Skip stage: {stage.iden!r}")
445
434
  continue
446
435
 
447
436
  logger.info(
448
- f"({self.run_id}) [JOB]: Start execute the stage: {name!r}"
437
+ f"({run_id}) [JOB]: Start execute the stage: {stage.iden!r}"
449
438
  )
450
439
 
451
440
  # NOTE: Logging a matrix that pass on this stage execution.
452
441
  if strategy:
453
- logger.info(f"({self.run_id}) [JOB]: Matrix: {strategy}")
442
+ logger.info(f"({run_id}) [JOB]: Matrix: {strategy}")
454
443
 
455
444
  # NOTE: Force stop this execution if event was set from main
456
445
  # execution.
@@ -475,6 +464,7 @@ class Job(BaseModel):
475
464
  ),
476
465
  },
477
466
  },
467
+ run_id=run_id,
478
468
  )
479
469
 
480
470
  # PARAGRAPH:
@@ -497,12 +487,12 @@ class Job(BaseModel):
497
487
  #
498
488
  try:
499
489
  stage.set_outputs(
500
- stage.execute(params=context).context,
490
+ stage.execute(params=context, run_id=run_id).context,
501
491
  to=context,
502
492
  )
503
493
  except (StageException, UtilException) as err:
504
494
  logger.error(
505
- f"({self.run_id}) [JOB]: {err.__class__.__name__}: {err}"
495
+ f"({run_id}) [JOB]: {err.__class__.__name__}: {err}"
506
496
  )
507
497
  if config.job_raise_error:
508
498
  raise JobException(
@@ -519,10 +509,10 @@ class Job(BaseModel):
519
509
  "error_message": f"{err.__class__.__name__}: {err}",
520
510
  },
521
511
  },
512
+ run_id=run_id,
522
513
  )
523
514
 
524
- # NOTE: Remove the current stage object that was created from
525
- # ``get_running_id`` method for saving memory.
515
+ # NOTE: Remove the current stage object.
526
516
  del stage
527
517
 
528
518
  return Result(
@@ -533,20 +523,23 @@ class Job(BaseModel):
533
523
  "stages": filter_func(context.pop("stages", {})),
534
524
  },
535
525
  },
526
+ run_id=run_id,
536
527
  )
537
528
 
538
- def execute(self, params: DictData | None = None) -> Result:
529
+ def execute(self, params: DictData, run_id: str | None = None) -> Result:
539
530
  """Job execution with passing dynamic parameters from the workflow
540
531
  execution. It will generate matrix values at the first step and run
541
532
  multithread on this metrics to the ``stages`` field of this job.
542
533
 
543
534
  :param params: An input parameters that use on job execution.
535
+ :param run_id: A job running ID for this execution.
536
+
544
537
  :rtype: Result
545
538
  """
546
539
 
547
540
  # NOTE: I use this condition because this method allow passing empty
548
541
  # params and I do not want to create new dict object.
549
- params: DictData = {} if params is None else params
542
+ run_id: str = run_id or gen_id(self.id or "", unique=True)
550
543
  context: DictData = {}
551
544
 
552
545
  # NOTE: Normal Job execution without parallel strategy.
@@ -555,6 +548,7 @@ class Job(BaseModel):
555
548
  rs: Result = self.execute_strategy(
556
549
  strategy=strategy,
557
550
  params=params,
551
+ run_id=run_id,
558
552
  )
559
553
  context.update(rs.context)
560
554
  return Result(
@@ -577,6 +571,7 @@ class Job(BaseModel):
577
571
  self.execute_strategy,
578
572
  strategy=strategy,
579
573
  params=params,
574
+ run_id=run_id,
580
575
  event=event,
581
576
  )
582
577
  for strategy in self.strategy.make()
@@ -584,15 +579,18 @@ class Job(BaseModel):
584
579
 
585
580
  # NOTE: Dynamic catching futures object with fail-fast flag.
586
581
  return (
587
- self.__catch_fail_fast(event=event, futures=futures)
582
+ self.__catch_fail_fast(
583
+ event=event, futures=futures, run_id=run_id
584
+ )
588
585
  if self.strategy.fail_fast
589
- else self.__catch_all_completed(futures=futures)
586
+ else self.__catch_all_completed(futures=futures, run_id=run_id)
590
587
  )
591
588
 
589
+ @staticmethod
592
590
  def __catch_fail_fast(
593
- self,
594
591
  event: Event,
595
592
  futures: list[Future],
593
+ run_id: str,
596
594
  *,
597
595
  timeout: int = 1800,
598
596
  result_timeout: int = 60,
@@ -604,6 +602,7 @@ class Job(BaseModel):
604
602
  :param event: An event manager instance that able to set stopper on the
605
603
  observing thread/process.
606
604
  :param futures: A list of futures.
605
+ :param run_id: A job running ID from execution.
607
606
  :param timeout: A timeout to waiting all futures complete.
608
607
  :param result_timeout: A timeout of getting result from the future
609
608
  instance when it was running completely.
@@ -623,7 +622,7 @@ class Job(BaseModel):
623
622
  nd: str = (
624
623
  f", the strategies do not run is {not_done}" if not_done else ""
625
624
  )
626
- 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}")
627
626
 
628
627
  # NOTE:
629
628
  # Stop all running tasks with setting the event manager and cancel
@@ -639,7 +638,7 @@ class Job(BaseModel):
639
638
  if err := future.exception():
640
639
  status: int = 1
641
640
  logger.error(
642
- f"({self.run_id}) [JOB]: One stage failed with: "
641
+ f"({run_id}) [JOB]: One stage failed with: "
643
642
  f"{future.exception()}, shutting down this future."
644
643
  )
645
644
  context.update(
@@ -655,9 +654,10 @@ class Job(BaseModel):
655
654
 
656
655
  return rs_final.catch(status=status, context=context)
657
656
 
657
+ @staticmethod
658
658
  def __catch_all_completed(
659
- self,
660
659
  futures: list[Future],
660
+ run_id: str,
661
661
  *,
662
662
  timeout: int = 1800,
663
663
  result_timeout: int = 60,
@@ -666,6 +666,7 @@ class Job(BaseModel):
666
666
 
667
667
  :param futures: A list of futures that want to catch all completed
668
668
  result.
669
+ :param run_id: A job running ID from execution.
669
670
  :param timeout: A timeout to waiting all futures complete.
670
671
  :param result_timeout: A timeout of getting result from the future
671
672
  instance when it was running completely.
@@ -680,7 +681,7 @@ class Job(BaseModel):
680
681
  except TimeoutError: # pragma: no cov
681
682
  status = 1
682
683
  logger.warning(
683
- f"({self.run_id}) [JOB]: Task is hanging. Attempting to "
684
+ f"({run_id}) [JOB]: Task is hanging. Attempting to "
684
685
  f"kill."
685
686
  )
686
687
  future.cancel()
@@ -691,11 +692,11 @@ class Job(BaseModel):
691
692
  if not future.cancelled()
692
693
  else "Task canceled successfully."
693
694
  )
694
- logger.warning(f"({self.run_id}) [JOB]: {stmt}")
695
+ logger.warning(f"({run_id}) [JOB]: {stmt}")
695
696
  except JobException as err:
696
697
  status = 1
697
698
  logger.error(
698
- f"({self.run_id}) [JOB]: Get stage exception with "
699
+ f"({run_id}) [JOB]: Get stage exception with "
699
700
  f"fail-fast does not set;\n{err.__class__.__name__}:\n\t"
700
701
  f"{err}"
701
702
  )
@@ -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):