ddeutil-workflow 0.0.16__py3-none-any.whl → 0.0.18__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
@@ -30,13 +30,12 @@ from pydantic.functional_validators import field_validator, model_validator
30
30
  from typing_extensions import Self
31
31
 
32
32
  from .__types import DictData, DictStr, Matrix, TupleStr
33
- from .conf import config
33
+ from .conf import config, get_logger
34
34
  from .exceptions import (
35
35
  JobException,
36
36
  StageException,
37
37
  UtilException,
38
38
  )
39
- from .log import get_logger
40
39
  from .stage import Stage
41
40
  from .utils import (
42
41
  Result,
@@ -111,6 +110,7 @@ def make(
111
110
  all(inc.get(k) == v for k, v in m.items()) for m in [*final, *add]
112
111
  ):
113
112
  continue
113
+
114
114
  add.append(inc)
115
115
 
116
116
  # NOTE: Merge all matrix together.
@@ -273,11 +273,32 @@ class Job(BaseModel):
273
273
 
274
274
  @field_validator("desc", mode="after")
275
275
  def ___prepare_desc__(cls, value: str) -> str:
276
- """Prepare description string that was created on a template."""
276
+ """Prepare description string that was created on a template.
277
+
278
+ :rtype: str
279
+ """
277
280
  return dedent(value)
278
281
 
282
+ @field_validator("stages", mode="after")
283
+ def __validate_stage_id__(cls, value: list[Stage]) -> list[Stage]:
284
+ """Validate a stage ID of all stage in stages field should not be
285
+ duplicate.
286
+
287
+ :rtype: list[Stage]
288
+ """
289
+ # VALIDATE: Validate stage id should not duplicate.
290
+ rs: list[str] = []
291
+ for stage in value:
292
+ name: str = stage.id or stage.name
293
+ if name in rs:
294
+ raise ValueError(
295
+ "Stage name in jobs object should not be duplicate."
296
+ )
297
+ rs.append(name)
298
+ return value
299
+
279
300
  @model_validator(mode="after")
280
- def __prepare_running_id__(self) -> Self:
301
+ def __prepare_running_id_and_stage_name__(self) -> Self:
281
302
  """Prepare the job running ID.
282
303
 
283
304
  :rtype: Self
@@ -355,7 +376,7 @@ class Job(BaseModel):
355
376
  to["jobs"][_id] = (
356
377
  {"strategies": output}
357
378
  if self.strategy.is_set()
358
- else output[next(iter(output))]
379
+ else output.get(next(iter(output), "DUMMY"), {})
359
380
  )
360
381
  return to
361
382
 
@@ -365,7 +386,6 @@ class Job(BaseModel):
365
386
  params: DictData,
366
387
  *,
367
388
  event: Event | None = None,
368
- raise_error: bool = True,
369
389
  ) -> Result:
370
390
  """Job Strategy execution with passing dynamic parameters from the
371
391
  workflow execution to strategy matrix.
@@ -374,19 +394,20 @@ class Job(BaseModel):
374
394
  It different with ``self.execute`` because this method run only one
375
395
  strategy and return with context of this strategy data.
376
396
 
377
- :raise JobException: If it has any error from StageException or
378
- UtilException.
397
+ :raise JobException: If it has any error from ``StageException`` or
398
+ ``UtilException``.
379
399
 
380
400
  :param strategy: A metrix strategy value.
381
401
  :param params: A dynamic parameters.
382
402
  :param event: An manger event that pass to the PoolThreadExecutor.
383
- :param raise_error: A flag that raise error instead catching to result
384
- if it get exception from stage execution.
403
+
385
404
  :rtype: Result
386
405
  """
387
406
  strategy_id: str = gen_id(strategy)
388
407
 
389
- # NOTE: Create strategy execution context and update a matrix and copied
408
+ # PARAGRAPH:
409
+ #
410
+ # Create strategy execution context and update a matrix and copied
390
411
  # of params. So, the context value will have structure like;
391
412
  #
392
413
  # {
@@ -405,14 +426,14 @@ class Job(BaseModel):
405
426
  # IMPORTANT: Change any stage running IDs to this job running ID.
406
427
  stage: Stage = stage.get_running_id(self.run_id)
407
428
 
408
- _st_name: str = stage.id or stage.name
429
+ name: str = stage.id or stage.name
409
430
 
410
431
  if stage.is_skipped(params=context):
411
- logger.info(f"({self.run_id}) [JOB]: Skip stage: {_st_name!r}")
432
+ logger.info(f"({self.run_id}) [JOB]: Skip stage: {name!r}")
412
433
  continue
413
434
 
414
435
  logger.info(
415
- f"({self.run_id}) [JOB]: Start execute the stage: {_st_name!r}"
436
+ f"({self.run_id}) [JOB]: Start execute the stage: {name!r}"
416
437
  )
417
438
 
418
439
  # NOTE: Logging a matrix that pass on this stage execution.
@@ -432,20 +453,20 @@ class Job(BaseModel):
432
453
  # ---
433
454
  # "stages": filter_func(context.pop("stages", {})),
434
455
  "stages": context.pop("stages", {}),
435
- # NOTE: Set the error keys.
436
456
  "error": JobException(
437
- "Process Event stopped before execution"
457
+ "Job strategy was canceled from trigger event "
458
+ "that had stopped before execution."
459
+ ),
460
+ "error_message": (
461
+ "Job strategy was canceled from trigger event "
462
+ "that had stopped before execution."
438
463
  ),
439
- "error_message": {
440
- "message": (
441
- "Process Event stopped before execution"
442
- ),
443
- },
444
464
  },
445
465
  },
446
466
  )
447
467
 
448
- # NOTE:
468
+ # PARAGRAPH:
469
+ #
449
470
  # I do not use below syntax because `params` dict be the
450
471
  # reference memory pointer and it was changed when I action
451
472
  # anything like update or re-construct this.
@@ -471,16 +492,25 @@ class Job(BaseModel):
471
492
  logger.error(
472
493
  f"({self.run_id}) [JOB]: {err.__class__.__name__}: {err}"
473
494
  )
474
- if raise_error:
495
+ if config.job_raise_error:
475
496
  raise JobException(
476
497
  f"Get stage execution error: {err.__class__.__name__}: "
477
498
  f"{err}"
478
499
  ) from None
479
- else:
480
- raise NotImplementedError() from None
500
+ return Result(
501
+ status=1,
502
+ context={
503
+ strategy_id: {
504
+ "matrix": strategy,
505
+ "stages": context.pop("stages", {}),
506
+ "error": err,
507
+ "error_message": f"{err.__class__.__name__}: {err}",
508
+ },
509
+ },
510
+ )
481
511
 
482
- # NOTE: Remove new stage object that was created from
483
- # ``get_running_id`` method.
512
+ # NOTE: Remove the current stage object that was created from
513
+ # ``get_running_id`` method for saving memory.
484
514
  del stage
485
515
 
486
516
  return Result(
@@ -583,30 +613,34 @@ class Job(BaseModel):
583
613
  )
584
614
  logger.debug(f"({self.run_id}) [JOB]: Strategy is set Fail Fast{nd}")
585
615
 
586
- # NOTE: Stop all running tasks with setting the event manager and cancel
616
+ # NOTE:
617
+ # Stop all running tasks with setting the event manager and cancel
587
618
  # any scheduled tasks.
619
+ #
588
620
  if len(done) != len(futures):
589
621
  event.set()
590
- for future in futures:
622
+ for future in not_done:
591
623
  future.cancel()
592
624
 
593
- del future
594
-
625
+ future: Future
595
626
  for future in done:
596
- if future.exception():
597
- status = 1
627
+ if err := future.exception():
628
+ status: int = 1
598
629
  logger.error(
599
630
  f"({self.run_id}) [JOB]: One stage failed with: "
600
631
  f"{future.exception()}, shutting down this future."
601
632
  )
602
- elif future.cancelled():
633
+ context.update(
634
+ {
635
+ "error": err,
636
+ "error_message": f"{err.__class__.__name__}: {err}",
637
+ },
638
+ )
603
639
  continue
604
640
 
605
641
  # NOTE: Update the result context to main job context.
606
642
  context.update(future.result(timeout=result_timeout).context)
607
643
 
608
- del future
609
-
610
644
  return rs_final.catch(status=status, context=context)
611
645
 
612
646
  def __catch_all_completed(
@@ -631,7 +665,7 @@ class Job(BaseModel):
631
665
  for future in as_completed(futures, timeout=timeout):
632
666
  try:
633
667
  context.update(future.result(timeout=result_timeout).context)
634
- except TimeoutError:
668
+ except TimeoutError: # pragma: no cov
635
669
  status = 1
636
670
  logger.warning(
637
671
  f"({self.run_id}) [JOB]: Task is hanging. Attempting to "
@@ -653,6 +687,10 @@ class Job(BaseModel):
653
687
  f"fail-fast does not set;\n{err.__class__.__name__}:\n\t"
654
688
  f"{err}"
655
689
  )
656
- finally:
657
- del future
690
+ context.update(
691
+ {
692
+ "error": err,
693
+ "error_message": f"{err.__class__.__name__}: {err}",
694
+ },
695
+ )
658
696
  return rs_final.catch(status=status, context=context)
ddeutil/workflow/on.py CHANGED
@@ -14,9 +14,9 @@ from pydantic.functional_serializers import field_serializer
14
14
  from pydantic.functional_validators import field_validator, model_validator
15
15
  from typing_extensions import Self
16
16
 
17
+ from .__cron import WEEKDAYS, CronJob, CronJobYear, CronRunner
17
18
  from .__types import DictData, DictStr, TupleStr
18
19
  from .conf import Loader
19
- from .cron import WEEKDAYS, CronJob, CronJobYear, CronRunner
20
20
 
21
21
  __all__: TupleStr = (
22
22
  "On",
@@ -109,7 +109,7 @@ class On(BaseModel):
109
109
  def from_loader(
110
110
  cls,
111
111
  name: str,
112
- externals: DictData,
112
+ externals: DictData | None = None,
113
113
  ) -> Self:
114
114
  """Constructor from the name of config that will use loader object for
115
115
  getting the data.
@@ -117,6 +117,7 @@ class On(BaseModel):
117
117
  :param name: A name of config that will getting from loader.
118
118
  :param externals: A extras external parameter that will keep in extras.
119
119
  """
120
+ externals: DictData = externals or {}
120
121
  loader: Loader = Loader(name, externals=externals)
121
122
 
122
123
  # NOTE: Validate the config type match with current connection model
@@ -139,7 +140,9 @@ class On(BaseModel):
139
140
  )
140
141
  )
141
142
  if "cronjob" not in loader_data:
142
- raise ValueError("Config does not set ``cronjob`` key")
143
+ raise ValueError(
144
+ "Config does not set ``cronjob`` or ``interval`` keys"
145
+ )
143
146
  return cls.model_validate(
144
147
  obj=dict(
145
148
  cronjob=loader_data.pop("cronjob"),
@@ -175,17 +178,17 @@ class On(BaseModel):
175
178
 
176
179
  def generate(self, start: str | datetime) -> CronRunner:
177
180
  """Return Cron runner object."""
178
- if not isinstance(start, datetime):
181
+ if isinstance(start, str):
179
182
  start: datetime = datetime.fromisoformat(start)
183
+ elif not isinstance(start, datetime):
184
+ raise TypeError("start value should be str or datetime type.")
180
185
  return self.cronjob.schedule(date=start, tz=self.tz)
181
186
 
182
187
  def next(self, start: str | datetime) -> datetime:
183
188
  """Return a next datetime from Cron runner object that start with any
184
189
  date that given from input.
185
190
  """
186
- if not isinstance(start, datetime):
187
- start: datetime = datetime.fromisoformat(start)
188
- return self.cronjob.schedule(date=start, tz=self.tz).next
191
+ return self.generate(start=start).next
189
192
 
190
193
 
191
194
  class YearOn(On):
@@ -12,9 +12,8 @@ from functools import wraps
12
12
 
13
13
  from starlette.concurrency import run_in_threadpool
14
14
 
15
- from .conf import config
16
- from .cron import CronJob
17
- from .log import get_logger
15
+ from .__cron import CronJob
16
+ from .conf import config, get_logger
18
17
 
19
18
  logger = get_logger("ddeutil.workflow")
20
19
 
ddeutil/workflow/route.py CHANGED
@@ -16,8 +16,7 @@ from pydantic import BaseModel
16
16
 
17
17
  from . import Workflow
18
18
  from .__types import DictData
19
- from .conf import Loader, config
20
- from .log import get_logger
19
+ from .conf import Loader, config, get_logger
21
20
  from .scheduler import Schedule
22
21
  from .utils import Result
23
22
 
@@ -52,15 +52,14 @@ except ImportError:
52
52
 
53
53
  try:
54
54
  from schedule import CancelJob
55
- except ImportError:
55
+ except ImportError: # pragma: no cov
56
56
  CancelJob = None
57
57
 
58
+ from .__cron import CronRunner
58
59
  from .__types import DictData, TupleStr
59
- from .conf import Loader, config
60
- from .cron import CronRunner
60
+ from .conf import FileLog, Loader, Log, config, get_logger
61
61
  from .exceptions import JobException, WorkflowException
62
62
  from .job import Job
63
- from .log import FileLog, Log, get_logger
64
63
  from .on import On
65
64
  from .utils import (
66
65
  Param,
@@ -230,8 +229,8 @@ class Workflow(BaseModel):
230
229
  need for need in self.jobs[job].needs if need not in self.jobs
231
230
  ]:
232
231
  raise WorkflowException(
233
- f"This needed jobs: {not_exist} do not exist in this "
234
- f"workflow, {self.name!r}"
232
+ f"The needed jobs: {not_exist} do not found in "
233
+ f"{self.name!r}."
235
234
  )
236
235
 
237
236
  # NOTE: update a job id with its job id from workflow template
@@ -354,11 +353,11 @@ class Workflow(BaseModel):
354
353
  # NOTE: get next schedule time that generate from now.
355
354
  next_time: datetime = gen.next
356
355
 
357
- # NOTE: get next utils it does not logger.
356
+ # NOTE: While-loop to getting next until it does not logger.
358
357
  while log.is_pointed(self.name, next_time, queue=queue):
359
358
  next_time: datetime = gen.next
360
359
 
361
- # NOTE: push this next running time to log queue
360
+ # NOTE: Heap-push this next running time to log queue list.
362
361
  heappush(queue, next_time)
363
362
 
364
363
  # VALIDATE: Check the different time between the next schedule time and
@@ -377,7 +376,7 @@ class Workflow(BaseModel):
377
376
  status=0,
378
377
  context={
379
378
  "params": params,
380
- "poking": {"skipped": [str(on.cronjob)], "run": []},
379
+ "release": {"status": "skipped", "cron": [str(on.cronjob)]},
381
380
  },
382
381
  )
383
382
 
@@ -389,7 +388,7 @@ class Workflow(BaseModel):
389
388
  # NOTE: Release when the time is nearly to schedule time.
390
389
  while (duration := get_diff_sec(next_time, tz=cron_tz)) > (
391
390
  sleep_interval + 5
392
- ):
391
+ ): # pragma: no cov
393
392
  logger.debug(
394
393
  f"({self.run_id}) [CORE]: {self.name!r} : {on.cronjob} : "
395
394
  f"Sleep until: {duration}"
@@ -440,7 +439,7 @@ class Workflow(BaseModel):
440
439
  status=0,
441
440
  context={
442
441
  "params": params,
443
- "poking": {"skipped": [], "run": [str(on.cronjob)]},
442
+ "release": {"status": "run", "cron": [str(on.cronjob)]},
444
443
  },
445
444
  )
446
445
 
@@ -493,7 +492,7 @@ class Workflow(BaseModel):
493
492
  for future in as_completed(futures):
494
493
  results.append(future.result(timeout=60))
495
494
 
496
- if len(queue) > 0:
495
+ if len(queue) > 0: # pragma: no cov
497
496
  logger.error(
498
497
  f"({self.run_id}) [POKING]: Log Queue does empty when poking "
499
498
  f"process was finishing."
@@ -709,7 +708,7 @@ class Workflow(BaseModel):
709
708
  raise WorkflowException(f"{err}")
710
709
  try:
711
710
  future.result(timeout=60)
712
- except TimeoutError as err:
711
+ except TimeoutError as err: # pragma: no cove
713
712
  raise WorkflowException(
714
713
  "Timeout when getting result from future"
715
714
  ) from err
@@ -718,11 +717,11 @@ class Workflow(BaseModel):
718
717
  return context
719
718
 
720
719
  # NOTE: Raise timeout error.
721
- logger.warning(
720
+ logger.warning( # pragma: no cov
722
721
  f"({self.run_id}) [WORKFLOW]: Execution of workflow, {self.name!r} "
723
722
  f", was timeout"
724
723
  )
725
- raise WorkflowException(
724
+ raise WorkflowException( # pragma: no cov
726
725
  f"Execution of workflow: {self.name} was timeout"
727
726
  )
728
727
 
@@ -766,7 +765,8 @@ class Workflow(BaseModel):
766
765
  continue
767
766
 
768
767
  # NOTE: Start workflow job execution with deep copy context data
769
- # before release.
768
+ # before release. This job execution process will running until
769
+ # done before checking all execution timeout or not.
770
770
  #
771
771
  # {
772
772
  # 'params': <input-params>,
@@ -784,10 +784,10 @@ class Workflow(BaseModel):
784
784
  return context
785
785
 
786
786
  # NOTE: Raise timeout error.
787
- logger.warning(
787
+ logger.warning( # pragma: no cov
788
788
  f"({self.run_id}) [WORKFLOW]: Execution of workflow was timeout"
789
789
  )
790
- raise WorkflowException(
790
+ raise WorkflowException( # pragma: no cov
791
791
  f"Execution of workflow: {self.name} was timeout"
792
792
  )
793
793
 
@@ -833,12 +833,13 @@ class ScheduleWorkflow(BaseModel):
833
833
  if on := data.pop("on", []):
834
834
 
835
835
  if isinstance(on, str):
836
- on = [on]
836
+ on: list[str] = [on]
837
837
 
838
838
  if any(not isinstance(n, (dict, str)) for n in on):
839
839
  raise TypeError("The ``on`` key should be list of str or dict")
840
840
 
841
- # NOTE: Pass on value to Loader and keep on model object to on field
841
+ # NOTE: Pass on value to Loader and keep on model object to on
842
+ # field.
842
843
  data["on"] = [
843
844
  (
844
845
  Loader(n, externals=(externals or {})).data
@@ -903,12 +904,14 @@ class Schedule(BaseModel):
903
904
  *,
904
905
  externals: DictData | None = None,
905
906
  ) -> list[WorkflowTaskData]:
906
- """Generate Task from the current datetime.
907
+ """Return the list of WorkflowTaskData object from the specific input
908
+ datetime that mapping with the on field.
907
909
 
908
910
  :param start_date: A start date that get from the workflow schedule.
909
911
  :param queue: A mapping of name and list of datetime for queue.
910
912
  :param running: A mapping of name and list of datetime for running.
911
913
  :param externals: An external parameters that pass to the Loader object.
914
+
912
915
  :rtype: list[WorkflowTaskData]
913
916
  """
914
917
 
@@ -923,12 +926,14 @@ class Schedule(BaseModel):
923
926
  queue[wfs.name]: list[datetime] = []
924
927
  running[wfs.name]: list[datetime] = []
925
928
 
926
- # NOTE: Create default on if it does not passing on the Schedule.
929
+ # NOTE: Create the default on value if it does not passing on the
930
+ # Schedule object.
927
931
  _ons: list[On] = wf.on.copy() if len(wfs.on) == 0 else wfs.on
928
932
 
929
933
  for on in _ons:
930
- on_gen = on.generate(start_date)
934
+ on_gen: CronRunner = on.generate(start_date)
931
935
  next_running_date = on_gen.next
936
+
932
937
  while next_running_date in queue[wfs.name]:
933
938
  next_running_date = on_gen.next
934
939
 
@@ -958,13 +963,14 @@ def catch_exceptions(cancel_on_failure: bool = False) -> DecoratorCancelJob:
958
963
 
959
964
  :param cancel_on_failure: A flag that allow to return the CancelJob or not
960
965
  it will raise.
961
- :rtype: Callable[P, Optional[CancelJob]]
966
+
967
+ :rtype: DecoratorCancelJob
962
968
  """
963
969
 
964
970
  def decorator(func: ReturnCancelJob) -> ReturnCancelJob:
965
971
  try:
966
972
  # NOTE: Check the function that want to handle is method or not.
967
- if inspect.ismethod(func):
973
+ if inspect.ismethod(func): # pragma: no cov
968
974
 
969
975
  @wraps(func)
970
976
  def wrapper(self, *args, **kwargs):
@@ -978,7 +984,7 @@ def catch_exceptions(cancel_on_failure: bool = False) -> DecoratorCancelJob:
978
984
 
979
985
  return wrapper
980
986
 
981
- except Exception as err:
987
+ except Exception as err: # pragma: no cov
982
988
  logger.exception(err)
983
989
  if cancel_on_failure:
984
990
  return CancelJob
@@ -1006,7 +1012,7 @@ class WorkflowTaskData:
1006
1012
  *,
1007
1013
  waiting_sec: int = 60,
1008
1014
  sleep_interval: int = 15,
1009
- ) -> None:
1015
+ ) -> None: # pragma: no cov
1010
1016
  """Workflow release, it will use with the same logic of
1011
1017
  `workflow.release` method.
1012
1018
 
@@ -1120,7 +1126,7 @@ class WorkflowTaskData:
1120
1126
  future_running_time in self.running[wf.name]
1121
1127
  or future_running_time in self.queue[wf.name]
1122
1128
  or future_running_time < finish_time
1123
- ):
1129
+ ): # pragma: no cov
1124
1130
  future_running_time: datetime = gen.next
1125
1131
 
1126
1132
  heappush(self.queue[wf.name], future_running_time)
@@ -1135,7 +1141,7 @@ class WorkflowTaskData:
1135
1141
  return NotImplemented
1136
1142
 
1137
1143
 
1138
- @catch_exceptions(cancel_on_failure=True)
1144
+ @catch_exceptions(cancel_on_failure=True) # pragma: no cov
1139
1145
  def workflow_task(
1140
1146
  workflow_tasks: list[WorkflowTaskData],
1141
1147
  stop: datetime,
@@ -1234,7 +1240,7 @@ def workflow_task(
1234
1240
  logger.debug(f"[WORKFLOW]: {'=' * 100}")
1235
1241
 
1236
1242
 
1237
- def workflow_monitor(threads: dict[str, Thread]) -> None:
1243
+ def workflow_monitor(threads: dict[str, Thread]) -> None: # pragma: no cov
1238
1244
  """Workflow schedule for monitoring long running thread from the schedule
1239
1245
  control.
1240
1246
 
@@ -1256,7 +1262,7 @@ def workflow_control(
1256
1262
  schedules: list[str],
1257
1263
  stop: datetime | None = None,
1258
1264
  externals: DictData | None = None,
1259
- ) -> list[str]:
1265
+ ) -> list[str]: # pragma: no cov
1260
1266
  """Workflow scheduler control.
1261
1267
 
1262
1268
  :param schedules: A list of workflow names that want to schedule running.
@@ -1344,7 +1350,7 @@ def workflow_runner(
1344
1350
  stop: datetime | None = None,
1345
1351
  externals: DictData | None = None,
1346
1352
  excluded: list[str] | None = None,
1347
- ) -> list[str]:
1353
+ ) -> list[str]: # pragma: no cov
1348
1354
  """Workflow application that running multiprocessing schedule with chunk of
1349
1355
  workflows that exists in config path.
1350
1356