ddeutil-workflow 0.0.49__py3-none-any.whl → 0.0.51__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/utils.py CHANGED
@@ -26,9 +26,11 @@ T = TypeVar("T")
26
26
  UTC: Final[ZoneInfo] = ZoneInfo("UTC")
27
27
 
28
28
 
29
- def get_dt_now(
30
- tz: ZoneInfo | None = None, offset: float = 0.0
31
- ) -> datetime: # pragma: no cov
29
+ def replace_sec(dt: datetime) -> datetime:
30
+ return dt.replace(second=0, microsecond=0)
31
+
32
+
33
+ def get_dt_now(tz: ZoneInfo | None = None, offset: float = 0.0) -> datetime:
32
34
  """Return the current datetime object.
33
35
 
34
36
  :param tz: A ZoneInfo object for replace timezone of return datetime object.
@@ -54,42 +56,31 @@ def get_d_now(
54
56
  return (datetime.now(tz=(tz or UTC)) - timedelta(seconds=offset)).date()
55
57
 
56
58
 
57
- def get_diff_sec(
58
- dt: datetime, tz: ZoneInfo | None = None, offset: float = 0.0
59
- ) -> int: # pragma: no cov
59
+ def get_diff_sec(dt: datetime, offset: float = 0.0) -> int:
60
60
  """Return second value that come from diff of an input datetime and the
61
61
  current datetime with specific timezone.
62
62
 
63
- :param dt:
64
- :param tz: A ZoneInfo object for replace timezone of return datetime object.
65
- :param offset: An offset second value.
63
+ :param dt: (datetime) A datetime object that want to get different second value.
64
+ :param offset: (float) An offset second value.
66
65
 
67
66
  :rtype: int
68
67
  """
69
68
  return round(
70
69
  (
71
- dt - datetime.now(tz=(tz or UTC)) - timedelta(seconds=offset)
70
+ dt - datetime.now(tz=dt.tzinfo) - timedelta(seconds=offset)
72
71
  ).total_seconds()
73
72
  )
74
73
 
75
74
 
76
- def reach_next_minute(
77
- dt: datetime, tz: ZoneInfo | None = None, offset: float = 0.0
78
- ) -> bool:
75
+ def reach_next_minute(dt: datetime, offset: float = 0.0) -> bool:
79
76
  """Check this datetime object is not in range of minute level on the current
80
77
  datetime.
81
78
 
82
- :param dt:
83
- :param tz: A ZoneInfo object for replace timezone of return datetime object.
84
- :param offset: An offset second value.
79
+ :param dt: (datetime) A datetime object that want to check.
80
+ :param offset: (float) An offset second value.
85
81
  """
86
82
  diff: float = (
87
- dt.replace(second=0, microsecond=0)
88
- - (
89
- get_dt_now(tz=(tz or UTC), offset=offset).replace(
90
- second=0, microsecond=0
91
- )
92
- )
83
+ replace_sec(dt) - replace_sec(get_dt_now(tz=dt.tzinfo, offset=offset))
93
84
  ).total_seconds()
94
85
  if diff >= 60:
95
86
  return True
@@ -106,7 +97,7 @@ def wait_to_next_minute(
106
97
  dt: datetime, second: float = 0
107
98
  ) -> None: # pragma: no cov
108
99
  """Wait with sleep to the next minute with an offset second value."""
109
- future = dt.replace(second=0, microsecond=0) + timedelta(minutes=1)
100
+ future: datetime = replace_sec(dt) + timedelta(minutes=1)
110
101
  time.sleep((future - dt).total_seconds() + second)
111
102
 
112
103
 
@@ -114,7 +105,7 @@ def delay(second: float = 0) -> None: # pragma: no cov
114
105
  """Delay time that use time.sleep with random second value between
115
106
  0.00 - 0.99 seconds.
116
107
 
117
- :param second: A second number that want to adds-on random value.
108
+ :param second: (float) A second number that want to adds-on random value.
118
109
  """
119
110
  time.sleep(second + randrange(0, 99, step=10) / 100)
120
111
 
@@ -124,32 +115,42 @@ def gen_id(
124
115
  *,
125
116
  sensitive: bool = True,
126
117
  unique: bool = False,
118
+ simple_mode: bool | None = None,
119
+ extras: DictData | None = None,
127
120
  ) -> str:
128
- """Generate running ID for able to tracking. This generates process use `md5`
129
- algorithm function if ``WORKFLOW_CORE_WORKFLOW_ID_SIMPLE_MODE`` set to
130
- false. But it will cut this hashing value length to 10 it the setting value
131
- set to true.
121
+ """Generate running ID for able to tracking. This generates process use
122
+ `md5` algorithm function if `WORKFLOW_CORE_WORKFLOW_ID_SIMPLE_MODE` set
123
+ to false. But it will cut this hashing value length to 10 it the setting
124
+ value set to true.
125
+
126
+ Simple Mode:
127
+
128
+ ... 0000 00 00 00 00 00 000000 T 0000000000
129
+ ... year month day hour minute second microsecond sep simple-id
132
130
 
133
131
  :param value: A value that want to add to prefix before hashing with md5.
134
132
  :param sensitive: A flag that convert the value to lower case before hashing
135
133
  :param unique: A flag that add timestamp at microsecond level to value
136
134
  before hashing.
135
+ :param simple_mode: A flag for generate ID by simple mode.
136
+ :param extras: An extra parameter that use for override config value.
137
137
 
138
138
  :rtype: str
139
139
  """
140
- from .conf import config
140
+ from .conf import dynamic
141
141
 
142
142
  if not isinstance(value, str):
143
143
  value: str = str(value)
144
144
 
145
- if config.generate_id_simple_mode:
146
- return (
147
- f"{datetime.now(tz=config.tz):%Y%m%d%H%M%S%f}T" if unique else ""
148
- ) + hash_str(f"{(value if sensitive else value.lower())}", n=10)
145
+ dt: datetime = datetime.now(tz=dynamic("tz", extras=extras))
146
+ if dynamic("generate_id_simple_mode", f=simple_mode, extras=extras):
147
+ return (f"{dt:%Y%m%d%H%M%S%f}T" if unique else "") + hash_str(
148
+ f"{(value if sensitive else value.lower())}", n=10
149
+ )
149
150
 
150
151
  return md5(
151
152
  (
152
- (f"{datetime.now(tz=config.tz):%Y%m%d%H%M%S%f}T" if unique else "")
153
+ (f"{dt}T" if unique else "")
153
154
  + f"{(value if sensitive else value.lower())}"
154
155
  ).encode()
155
156
  ).hexdigest()
@@ -243,12 +244,15 @@ def cut_id(run_id: str, *, num: int = 6) -> str:
243
244
  """Cutting running ID with length.
244
245
 
245
246
  Example:
246
- >>> cut_id(run_id='668931127320241228100331254567')
247
- '254567'
247
+ >>> cut_id(run_id='20240101081330000000T1354680202')
248
+ '202401010813680202'
248
249
 
249
- :param run_id:
250
+ :param run_id: A running ID That want to cut
250
251
  :param num:
251
252
 
252
253
  :rtype: str
253
254
  """
254
- return run_id[-num:]
255
+ if "T" in run_id:
256
+ dt, simple = run_id.split("T", maxsplit=1)
257
+ return dt[:12] + simple[-num:]
258
+ return run_id[:12] + run_id[-num:]
@@ -6,6 +6,9 @@
6
6
  # [x] Use dynamic config
7
7
  """A Workflow module that is the core module of this package. It keeps Release
8
8
  and Workflow Pydantic models.
9
+
10
+ I will implement timeout on the workflow execution layer only because the
11
+ main propose of this package in Workflow model.
9
12
  """
10
13
  from __future__ import annotations
11
14
 
@@ -36,7 +39,7 @@ from .__cron import CronJob, CronRunner
36
39
  from .__types import DictData, TupleStr
37
40
  from .conf import Loader, SimLoad, dynamic
38
41
  from .cron import On
39
- from .exceptions import JobException, WorkflowException
42
+ from .exceptions import JobException, UtilException, WorkflowException
40
43
  from .job import Job
41
44
  from .logs import Audit, get_audit
42
45
  from .params import Param
@@ -636,20 +639,20 @@ class Workflow(BaseModel):
636
639
  run_id: str | None = None,
637
640
  parent_run_id: str | None = None,
638
641
  audit: type[Audit] = None,
639
- queue: ReleaseQueue | None = None,
642
+ queue: Optional[ReleaseQueue] = None,
640
643
  override_log_name: str | None = None,
641
- result: Result | None = None,
644
+ result: Optional[Result] = None,
645
+ timeout: int = 600,
642
646
  ) -> Result:
643
647
  """Release the workflow execution with overriding parameter with the
644
648
  release templating that include logical date (release date), execution
645
649
  date, or running id to the params.
646
650
 
647
651
  This method allow workflow use audit object to save the execution
648
- result to audit destination like file audit to the local `/logs`
649
- directory.
652
+ result to audit destination like file audit to the local `./logs` path.
650
653
 
651
654
  Steps:
652
- - Initialize ReleaseQueue and Release if they do not pass.
655
+ - Initialize Release and validate ReleaseQueue.
653
656
  - Create release data for pass to parameter templating function.
654
657
  - Execute this workflow with mapping release data to its parameters.
655
658
  - Writing result audit
@@ -658,15 +661,15 @@ class Workflow(BaseModel):
658
661
 
659
662
  :param release: A release datetime or Release object.
660
663
  :param params: A workflow parameter that pass to execute method.
661
- :param queue: A ReleaseQueue that use for mark complete.
662
- :param run_id: A workflow running ID for this release.
663
- :param parent_run_id: A parent workflow running ID for this release.
664
+ :param run_id: (str) A workflow running ID.
665
+ :param parent_run_id: (str) A parent workflow running ID.
664
666
  :param audit: An audit class that want to save the execution result.
665
- :param queue: A ReleaseQueue object.
666
- :param override_log_name: An override logging name that use instead
667
- the workflow name.
667
+ :param queue: (ReleaseQueue) A ReleaseQueue object.
668
+ :param override_log_name: (str) An override logging name that use
669
+ instead the workflow name.
668
670
  :param result: (Result) A result object for keeping context and status
669
671
  data.
672
+ :param timeout: (int) A workflow execution time out in second unit.
670
673
 
671
674
  :raise TypeError: If a queue parameter does not match with ReleaseQueue
672
675
  type.
@@ -683,7 +686,8 @@ class Workflow(BaseModel):
683
686
  extras=self.extras,
684
687
  )
685
688
 
686
- if queue is not None and not isinstance(queue, ReleaseQueue):
689
+ # VALIDATE: check type of queue that valid with ReleaseQueue.
690
+ if queue and not isinstance(queue, ReleaseQueue):
687
691
  raise TypeError(
688
692
  "The queue argument should be ReleaseQueue object only."
689
693
  )
@@ -693,36 +697,29 @@ class Workflow(BaseModel):
693
697
  release: Release = Release.from_dt(release, extras=self.extras)
694
698
 
695
699
  result.trace.debug(
696
- f"[RELEASE]: Start release - {name!r} : "
697
- f"{release.date:%Y-%m-%d %H:%M:%S}"
700
+ f"[RELEASE]: Start {name!r} : {release.date:%Y-%m-%d %H:%M:%S}"
698
701
  )
699
-
700
- # NOTE: Release parameters that use to templating on the schedule
701
- # config data.
702
- release_params: DictData = {
703
- "release": {
704
- "logical_date": release.date,
705
- "execute_date": datetime.now(
706
- tz=dynamic("tz", extras=self.extras)
707
- ),
708
- "run_id": result.run_id,
709
- "timezone": dynamic("tz", extras=self.extras),
710
- }
711
- }
712
-
713
- # NOTE: Execute workflow with templating params from release mapping.
714
- # The result context that return from execution method is:
715
- #
716
- # ... {"params": ..., "jobs": ...}
717
- #
718
702
  self.execute(
719
- params=param2template(params, release_params, extras=self.extras),
703
+ params=param2template(
704
+ params,
705
+ params={
706
+ "release": {
707
+ "logical_date": release.date,
708
+ "execute_date": datetime.now(
709
+ tz=dynamic("tz", extras=self.extras)
710
+ ),
711
+ "run_id": result.run_id,
712
+ "timezone": dynamic("tz", extras=self.extras),
713
+ }
714
+ },
715
+ extras=self.extras,
716
+ ),
720
717
  result=result,
721
718
  parent_run_id=result.parent_run_id,
719
+ timeout=timeout,
722
720
  )
723
721
  result.trace.debug(
724
- f"[RELEASE]: End release - {name!r} : "
725
- f"{release.date:%Y-%m-%d %H:%M:%S}"
722
+ f"[RELEASE]: End {name!r} : {release.date:%Y-%m-%d %H:%M:%S}"
726
723
  )
727
724
 
728
725
  # NOTE: Saving execution result to destination of the input audit
@@ -741,19 +738,10 @@ class Workflow(BaseModel):
741
738
  ).save(excluded=None)
742
739
  )
743
740
 
744
- # NOTE: Remove this release from running.
745
- if queue is not None:
741
+ if queue:
746
742
  queue.remove_running(release)
747
743
  queue.mark_complete(release)
748
744
 
749
- # NOTE: Remove the params key from the result context for deduplicate.
750
- # This step is prepare result context for this release method.
751
- context: DictData = result.context
752
- jobs: DictData = context.pop("jobs", {})
753
- errors: DictData = (
754
- {"errors": context.pop("errors", {})} if "errors" in context else {}
755
- )
756
-
757
745
  return result.catch(
758
746
  status=SUCCESS,
759
747
  context={
@@ -763,8 +751,7 @@ class Workflow(BaseModel):
763
751
  "logical_date": release.date,
764
752
  "release": release,
765
753
  },
766
- "outputs": {"jobs": jobs},
767
- **errors,
754
+ "outputs": {"jobs": result.context.pop("jobs", {})},
768
755
  },
769
756
  )
770
757
 
@@ -923,15 +910,11 @@ class Workflow(BaseModel):
923
910
  # NOTE: Pop the latest Release object from the release queue.
924
911
  release: Release = heappop(q.queue)
925
912
 
926
- if reach_next_minute(
927
- release.date,
928
- tz=dynamic("tz", extras=self.extras),
929
- offset=offset,
930
- ):
913
+ if reach_next_minute(release.date, offset=offset):
931
914
  result.trace.debug(
932
- f"[POKING]: The latest release, "
933
- f"{release.date:%Y-%m-%d %H:%M:%S}, is not able to run "
934
- f"on this minute"
915
+ f"[POKING]: Latest Release, "
916
+ f"{release.date:%Y-%m-%d %H:%M:%S}, can not run on "
917
+ f"this time"
935
918
  )
936
919
  heappush(q.queue, release)
937
920
  wait_to_next_minute(
@@ -976,7 +959,6 @@ class Workflow(BaseModel):
976
959
  *,
977
960
  result: Result | None = None,
978
961
  event: Event | None = None,
979
- raise_error: bool = True,
980
962
  ) -> Result:
981
963
  """Job execution with passing dynamic parameters from the main workflow
982
964
  execution to the target job object via job's ID.
@@ -987,7 +969,6 @@ class Workflow(BaseModel):
987
969
 
988
970
  :raise WorkflowException: If execute with not exist job's ID.
989
971
  :raise WorkflowException: If the job execution raise JobException.
990
- :raise NotImplementedError: If set raise_error argument to False.
991
972
 
992
973
  :param job_id: A job ID that want to execute.
993
974
  :param params: A params that was parameterized from workflow execution.
@@ -995,8 +976,6 @@ class Workflow(BaseModel):
995
976
  data.
996
977
  :param event: (Event) An event manager that pass to the
997
978
  PoolThreadExecutor.
998
- :param raise_error: A flag that raise error instead catching to result
999
- if it gets exception from job execution.
1000
979
 
1001
980
  :rtype: Result
1002
981
  :return: Return the result object that receive the job execution result
@@ -1012,6 +991,12 @@ class Workflow(BaseModel):
1012
991
  f"workflow."
1013
992
  )
1014
993
 
994
+ job: Job = self.job(name=job_id)
995
+ if job.is_skipped(params=params):
996
+ result.trace.info(f"[WORKFLOW]: Skip job: {job_id!r}")
997
+ job.set_outputs(output={"skipped": True}, to=params)
998
+ return result.catch(status=SKIP, context=params)
999
+
1015
1000
  if event and event.is_set(): # pragma: no cov
1016
1001
  raise WorkflowException(
1017
1002
  "Workflow job was canceled from event that had set before "
@@ -1019,31 +1004,31 @@ class Workflow(BaseModel):
1019
1004
  )
1020
1005
 
1021
1006
  try:
1022
- job: Job = self.jobs[job_id]
1023
- if job.is_skipped(params=params):
1024
- result.trace.info(f"[JOB]: Skip job: {job_id!r}")
1025
- job.set_outputs(output={"SKIP": {"skipped": True}}, to=params)
1026
- else:
1027
- result.trace.info(f"[JOB]: Start execute job: {job_id!r}")
1028
- job.set_outputs(
1029
- job.execute(
1030
- params=params,
1031
- run_id=result.run_id,
1032
- parent_run_id=result.parent_run_id,
1033
- event=event,
1034
- ).context,
1035
- to=params,
1036
- )
1037
- except JobException as e:
1007
+ result.trace.info(f"[WORKFLOW]: Execute Job: {job_id!r}")
1008
+ rs: Result = job.execute(
1009
+ params=params,
1010
+ run_id=result.run_id,
1011
+ parent_run_id=result.parent_run_id,
1012
+ event=event,
1013
+ )
1014
+ job.set_outputs(rs.context, to=params)
1015
+ except (JobException, UtilException) as e:
1038
1016
  result.trace.error(f"[WORKFLOW]: {e.__class__.__name__}: {e}")
1039
- if raise_error:
1040
- raise WorkflowException(
1041
- f"Get job execution error {job_id}: JobException: {e}"
1042
- ) from None
1043
- raise NotImplementedError(
1044
- "Handle error from the job execution does not support yet."
1017
+ raise WorkflowException(
1018
+ f"Get job execution error {job_id}: JobException: {e}"
1045
1019
  ) from None
1046
1020
 
1021
+ if rs.status == FAILED:
1022
+ error_msg: str = (
1023
+ f"Workflow job, {job.id}, failed without raise error."
1024
+ )
1025
+ return result.catch(
1026
+ status=FAILED,
1027
+ context={
1028
+ "errors": WorkflowException(error_msg).to_dict(),
1029
+ **params,
1030
+ },
1031
+ )
1047
1032
  return result.catch(status=SUCCESS, context=params)
1048
1033
 
1049
1034
  def execute(
@@ -1095,7 +1080,7 @@ class Workflow(BaseModel):
1095
1080
  extras=self.extras,
1096
1081
  )
1097
1082
 
1098
- result.trace.info(f"[WORKFLOW]: Start Execute: {self.name!r} ...")
1083
+ result.trace.info(f"[WORKFLOW]: Execute: {self.name!r} ...")
1099
1084
  if not self.jobs:
1100
1085
  result.trace.warning(
1101
1086
  f"[WORKFLOW]: {self.name!r} does not have any jobs"
@@ -1140,7 +1125,7 @@ class Workflow(BaseModel):
1140
1125
  timeout=timeout,
1141
1126
  event=event,
1142
1127
  )
1143
- except WorkflowException as e:
1128
+ except (WorkflowException, JobException) as e:
1144
1129
  status: Status = FAILED
1145
1130
  context.update({"errors": e.to_dict()})
1146
1131
 
@@ -1179,7 +1164,7 @@ class Workflow(BaseModel):
1179
1164
  "max_job_exec_timeout", f=timeout, extras=self.extras
1180
1165
  )
1181
1166
  event: Event = event or Event()
1182
- result.trace.debug(f"[WORKFLOW]: Run {self.name!r} with threading.")
1167
+ result.trace.debug(f"... Run {self.name!r} with threading.")
1183
1168
  with ThreadPoolExecutor(
1184
1169
  max_workers=dynamic("max_job_parallel", extras=self.extras),
1185
1170
  thread_name_prefix="wf_exec_threading_",
@@ -1204,7 +1189,7 @@ class Workflow(BaseModel):
1204
1189
  )
1205
1190
  elif check == SKIP: # pragma: no cov
1206
1191
  result.trace.info(f"[JOB]: Skip job: {job_id!r}")
1207
- job.set_outputs({"SKIP": {"skipped": True}}, to=context)
1192
+ job.set_outputs(output={"skipped": True}, to=context)
1208
1193
  job_queue.task_done()
1209
1194
  continue
1210
1195
 
@@ -1271,12 +1256,12 @@ class Workflow(BaseModel):
1271
1256
  "max_job_exec_timeout", f=timeout, extras=self.extras
1272
1257
  )
1273
1258
  event: Event = event or Event()
1274
- result.trace.debug(f"[WORKFLOW]: Run {self.name!r} with non-threading.")
1259
+ result.trace.debug(f"... Run {self.name!r} with non-threading.")
1275
1260
  with ThreadPoolExecutor(
1276
1261
  max_workers=1,
1277
1262
  thread_name_prefix="wf_exec_non_threading_",
1278
1263
  ) as executor:
1279
- future: Future | None = None
1264
+ future: Optional[Future] = None
1280
1265
 
1281
1266
  while not job_queue.empty() and (
1282
1267
  not_timeout_flag := ((time.monotonic() - ts) < timeout)
@@ -1296,7 +1281,7 @@ class Workflow(BaseModel):
1296
1281
  )
1297
1282
  elif check == SKIP: # pragma: no cov
1298
1283
  result.trace.info(f"[JOB]: Skip job: {job_id!r}")
1299
- job.set_outputs({"SKIP": {"skipped": True}}, to=context)
1284
+ job.set_outputs(output={"skipped": True}, to=context)
1300
1285
  job_queue.task_done()
1301
1286
  continue
1302
1287
 
@@ -1309,27 +1294,33 @@ class Workflow(BaseModel):
1309
1294
  event=event,
1310
1295
  )
1311
1296
  time.sleep(0.025)
1312
- elif future.done():
1297
+ elif future.done() or future.cancelled():
1313
1298
  if e := future.exception():
1314
1299
  result.trace.error(f"[WORKFLOW]: {e}")
1315
1300
  raise WorkflowException(str(e))
1316
1301
 
1317
1302
  future = None
1318
1303
  job_queue.put(job_id)
1319
- elif future.running():
1304
+ elif future.running() or "state=pending" in str(future):
1320
1305
  time.sleep(0.075)
1321
1306
  job_queue.put(job_id)
1322
1307
  else: # pragma: no cov
1323
1308
  job_queue.put(job_id)
1324
- result.trace.debug(
1325
- f"Execution non-threading does not handle case: {future} "
1326
- f"that not running."
1309
+ result.trace.warning(
1310
+ f"... Execution non-threading not handle: {future}."
1327
1311
  )
1328
1312
 
1329
1313
  job_queue.task_done()
1330
1314
 
1331
1315
  if not_timeout_flag:
1332
1316
  job_queue.join()
1317
+ if future: # pragma: no cov
1318
+ if e := future.exception():
1319
+ result.trace.error(f"[WORKFLOW]: {e}")
1320
+ raise WorkflowException(str(e))
1321
+
1322
+ future.result()
1323
+
1333
1324
  return context
1334
1325
 
1335
1326
  result.trace.error(
@@ -1352,6 +1343,12 @@ class WorkflowTask:
1352
1343
 
1353
1344
  This dataclass has the release method for itself that prepare necessary
1354
1345
  arguments before passing to the parent release method.
1346
+
1347
+ :param alias: (str) An alias name of Workflow model.
1348
+ :param workflow: (Workflow) A Workflow model instance.
1349
+ :param runner: (CronRunner)
1350
+ :param values:
1351
+ :param extras:
1355
1352
  """
1356
1353
 
1357
1354
  alias: str