ddeutil-workflow 0.0.15__py3-none-any.whl → 0.0.16__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.
@@ -6,14 +6,13 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import asyncio
9
- import os
10
9
  from asyncio import ensure_future
11
10
  from datetime import datetime
12
11
  from functools import wraps
13
- from zoneinfo import ZoneInfo
14
12
 
15
13
  from starlette.concurrency import run_in_threadpool
16
14
 
15
+ from .conf import config
17
16
  from .cron import CronJob
18
17
  from .log import get_logger
19
18
 
@@ -24,9 +23,7 @@ def get_cronjob_delta(cron: str) -> float:
24
23
  """This function returns the time delta between now and the next cron
25
24
  execution time.
26
25
  """
27
- now: datetime = datetime.now(
28
- tz=ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
29
- )
26
+ now: datetime = datetime.now(tz=config.tz)
30
27
  cron = CronJob(cron)
31
28
  return (cron.schedule(now).next - now).total_seconds()
32
29
 
ddeutil/workflow/route.py CHANGED
@@ -6,10 +6,8 @@
6
6
  from __future__ import annotations
7
7
 
8
8
  import copy
9
- import os
10
9
  from datetime import datetime, timedelta
11
10
  from typing import Any
12
- from zoneinfo import ZoneInfo
13
11
 
14
12
  from fastapi import APIRouter, HTTPException, Request
15
13
  from fastapi import status as st
@@ -18,9 +16,10 @@ from pydantic import BaseModel
18
16
 
19
17
  from . import Workflow
20
18
  from .__types import DictData
19
+ from .conf import Loader, config
21
20
  from .log import get_logger
22
21
  from .scheduler import Schedule
23
- from .utils import Loader, Result
22
+ from .utils import Result
24
23
 
25
24
  logger = get_logger("ddeutil.workflow")
26
25
  workflow = APIRouter(
@@ -87,12 +86,7 @@ async def execute_workflow(name: str, payload: ExecutePayload) -> DictData:
87
86
  # NOTE: Start execute manually
88
87
  rs: Result = wf.execute(params=payload.params)
89
88
 
90
- return rs.model_dump(
91
- by_alias=True,
92
- exclude_none=True,
93
- exclude_unset=True,
94
- exclude_defaults=True,
95
- )
89
+ return dict(rs)
96
90
 
97
91
 
98
92
  @workflow.get("/{name}/logs")
@@ -172,8 +166,7 @@ async def add_deploy_scheduler(request: Request, name: str):
172
166
 
173
167
  request.state.scheduler.append(name)
174
168
 
175
- tz: ZoneInfo = ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
176
- start_date: datetime = datetime.now(tz=tz)
169
+ start_date: datetime = datetime.now(tz=config.tz)
177
170
  start_date_waiting: datetime = (start_date + timedelta(minutes=1)).replace(
178
171
  second=0, microsecond=0
179
172
  )
@@ -3,13 +3,26 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
+ """
7
+ The main schedule running is ``workflow_runner`` function that trigger the
8
+ multiprocess of ``workflow_control`` function for listing schedules on the
9
+ config by ``Loader.finds(Schedule)``.
10
+
11
+ The ``workflow_control`` is the scheduler function that release 2 schedule
12
+ functions; ``workflow_task``, and ``workflow_monitor``.
13
+
14
+ ``workflow_control`` --- Every minute at :02 --> ``workflow_task``
15
+ --- Every 5 minutes --> ``workflow_monitor``
16
+
17
+ The ``workflow_task`` will run ``task.release`` method in threading object
18
+ for multithreading strategy. This ``release`` method will run only one crontab
19
+ value with the on field.
20
+ """
6
21
  from __future__ import annotations
7
22
 
8
23
  import copy
9
24
  import inspect
10
- import json
11
25
  import logging
12
- import os
13
26
  import time
14
27
  from concurrent.futures import (
15
28
  Future,
@@ -43,14 +56,13 @@ except ImportError:
43
56
  CancelJob = None
44
57
 
45
58
  from .__types import DictData, TupleStr
46
- from .conf import config
59
+ from .conf import Loader, config
47
60
  from .cron import CronRunner
48
61
  from .exceptions import JobException, WorkflowException
49
62
  from .job import Job
50
63
  from .log import FileLog, Log, get_logger
51
64
  from .on import On
52
65
  from .utils import (
53
- Loader,
54
66
  Param,
55
67
  Result,
56
68
  batch,
@@ -75,7 +87,7 @@ __all__: TupleStr = (
75
87
  "Schedule",
76
88
  "ScheduleWorkflow",
77
89
  "workflow_task",
78
- "workflow_long_running_task",
90
+ "workflow_monitor",
79
91
  "workflow_control",
80
92
  "workflow_runner",
81
93
  )
@@ -184,7 +196,7 @@ class Workflow(BaseModel):
184
196
  return data
185
197
 
186
198
  @model_validator(mode="before")
187
- def __prepare_params(cls, values: DictData) -> DictData:
199
+ def __prepare_model_before__(cls, values: DictData) -> DictData:
188
200
  """Prepare the params key."""
189
201
  # NOTE: Prepare params type if it passing with only type value.
190
202
  if params := values.pop("params", {}):
@@ -199,9 +211,10 @@ class Workflow(BaseModel):
199
211
  return values
200
212
 
201
213
  @field_validator("desc", mode="after")
202
- def ___prepare_desc(cls, value: str) -> str:
214
+ def __dedent_desc__(cls, value: str) -> str:
203
215
  """Prepare description string that was created on a template.
204
216
 
217
+ :param value: A description string value that want to dedent.
205
218
  :rtype: str
206
219
  """
207
220
  return dedent(value)
@@ -458,8 +471,10 @@ class Workflow(BaseModel):
458
471
  queue: list[datetime] = []
459
472
  results: list[Result] = []
460
473
 
461
- worker: int = int(os.getenv("WORKFLOW_CORE_MAX_NUM_POKING") or "4")
462
- with ThreadPoolExecutor(max_workers=worker) as executor:
474
+ with ThreadPoolExecutor(
475
+ max_workers=config.max_poking_pool_worker,
476
+ thread_name_prefix="wf_poking_",
477
+ ) as executor:
463
478
  futures: list[Future] = []
464
479
  for on in self.on:
465
480
  futures.append(
@@ -795,7 +810,7 @@ class ScheduleWorkflow(BaseModel):
795
810
  )
796
811
 
797
812
  @model_validator(mode="before")
798
- def __prepare_values(cls, values: DictData) -> DictData:
813
+ def __prepare_before__(cls, values: DictData) -> DictData:
799
814
  """Prepare incoming values before validating with model fields.
800
815
 
801
816
  :rtype: DictData
@@ -933,9 +948,11 @@ class Schedule(BaseModel):
933
948
  return workflow_tasks
934
949
 
935
950
 
936
- def catch_exceptions(
937
- cancel_on_failure: bool = False,
938
- ) -> Callable[P, Optional[CancelJob]]:
951
+ ReturnCancelJob = Callable[P, Optional[CancelJob]]
952
+ DecoratorCancelJob = Callable[[ReturnCancelJob], ReturnCancelJob]
953
+
954
+
955
+ def catch_exceptions(cancel_on_failure: bool = False) -> DecoratorCancelJob:
939
956
  """Catch exception error from scheduler job that running with schedule
940
957
  package and return CancelJob if this function raise an error.
941
958
 
@@ -944,9 +961,7 @@ def catch_exceptions(
944
961
  :rtype: Callable[P, Optional[CancelJob]]
945
962
  """
946
963
 
947
- def decorator(
948
- func: Callable[P, Optional[CancelJob]],
949
- ) -> Callable[P, Optional[CancelJob]]:
964
+ def decorator(func: ReturnCancelJob) -> ReturnCancelJob:
950
965
  try:
951
966
  # NOTE: Check the function that want to handle is method or not.
952
967
  if inspect.ismethod(func):
@@ -981,8 +996,8 @@ class WorkflowTaskData:
981
996
  workflow: Workflow
982
997
  on: On
983
998
  params: DictData = field(compare=False, hash=False)
984
- queue: list[datetime] = field(compare=False, hash=False)
985
- running: list[datetime] = field(compare=False, hash=False)
999
+ queue: dict[str, list[datetime]] = field(compare=False, hash=False)
1000
+ running: dict[str, list[datetime]] = field(compare=False, hash=False)
986
1001
 
987
1002
  @catch_exceptions(cancel_on_failure=True)
988
1003
  def release(
@@ -1062,8 +1077,9 @@ class WorkflowTaskData:
1062
1077
  },
1063
1078
  }
1064
1079
 
1065
- # WARNING: Re-create workflow object that use new running workflow
1066
- # ID.
1080
+ # WARNING:
1081
+ # Re-create workflow object that use new running workflow ID.
1082
+ #
1067
1083
  runner: Workflow = wf.get_running_id(run_id=wf.new_run_id)
1068
1084
  rs: Result = runner.execute(
1069
1085
  params=param2template(self.params, release_params),
@@ -1116,6 +1132,7 @@ class WorkflowTaskData:
1116
1132
  self.workflow.name == other.workflow.name
1117
1133
  and self.on.cronjob == other.on.cronjob
1118
1134
  )
1135
+ return NotImplemented
1119
1136
 
1120
1137
 
1121
1138
  @catch_exceptions(cancel_on_failure=True)
@@ -1127,10 +1144,10 @@ def workflow_task(
1127
1144
  """Workflow task generator that create release pair of workflow and on to
1128
1145
  the threading in background.
1129
1146
 
1130
- This workflow task will start every minute at :02 second.
1147
+ This workflow task will start every minute at ':02' second.
1131
1148
 
1132
1149
  :param workflow_tasks:
1133
- :param stop:
1150
+ :param stop: A stop datetime object that force stop running scheduler.
1134
1151
  :param threads:
1135
1152
  :rtype: CancelJob | None
1136
1153
  """
@@ -1145,7 +1162,7 @@ def workflow_task(
1145
1162
  "running in background."
1146
1163
  )
1147
1164
  time.sleep(15)
1148
- workflow_long_running_task(threads)
1165
+ workflow_monitor(threads)
1149
1166
  return CancelJob
1150
1167
 
1151
1168
  # IMPORTANT:
@@ -1217,7 +1234,7 @@ def workflow_task(
1217
1234
  logger.debug(f"[WORKFLOW]: {'=' * 100}")
1218
1235
 
1219
1236
 
1220
- def workflow_long_running_task(threads: dict[str, Thread]) -> None:
1237
+ def workflow_monitor(threads: dict[str, Thread]) -> None:
1221
1238
  """Workflow schedule for monitoring long running thread from the schedule
1222
1239
  control.
1223
1240
 
@@ -1275,30 +1292,29 @@ def workflow_control(
1275
1292
  sch: Schedule = Schedule.from_loader(name, externals=externals)
1276
1293
  workflow_tasks.extend(
1277
1294
  sch.tasks(
1278
- start_date_waiting, wf_queue, wf_running, externals=externals
1295
+ start_date_waiting,
1296
+ queue=wf_queue,
1297
+ running=wf_running,
1298
+ externals=externals,
1279
1299
  ),
1280
1300
  )
1281
1301
 
1282
1302
  # NOTE: This schedule job will start every minute at :02 seconds.
1283
- schedule.every(1).minutes.at(":02").do(
1284
- workflow_task,
1285
- workflow_tasks=workflow_tasks,
1286
- stop=stop
1287
- or (
1288
- start_date
1289
- + timedelta(
1290
- **json.loads(
1291
- os.getenv("WORKFLOW_APP_STOP_BOUNDARY_DELTA")
1292
- or '{"minutes": 5, "seconds": 20}'
1293
- )
1294
- )
1295
- ),
1296
- threads=thread_releases,
1297
- ).tag("control")
1303
+ (
1304
+ schedule.every(1)
1305
+ .minutes.at(":02")
1306
+ .do(
1307
+ workflow_task,
1308
+ workflow_tasks=workflow_tasks,
1309
+ stop=(stop or (start_date + config.stop_boundary_delta)),
1310
+ threads=thread_releases,
1311
+ )
1312
+ .tag("control")
1313
+ )
1298
1314
 
1299
1315
  # NOTE: Checking zombie task with schedule job will start every 5 minute.
1300
1316
  schedule.every(5).minutes.at(":10").do(
1301
- workflow_long_running_task,
1317
+ workflow_monitor,
1302
1318
  threads=thread_releases,
1303
1319
  ).tag("monitor")
1304
1320
 
@@ -1332,14 +1348,16 @@ def workflow_runner(
1332
1348
  """Workflow application that running multiprocessing schedule with chunk of
1333
1349
  workflows that exists in config path.
1334
1350
 
1335
- :param stop:
1351
+ :param stop: A stop datetime object that force stop running scheduler.
1336
1352
  :param excluded:
1337
1353
  :param externals:
1354
+
1338
1355
  :rtype: list[str]
1339
1356
 
1340
1357
  This function will get all workflows that include on value that was
1341
- created in config path and chuck it with WORKFLOW_APP_SCHEDULE_PER_PROCESS
1342
- value to multiprocess executor pool.
1358
+ created in config path and chuck it with application config variable
1359
+ ``WORKFLOW_APP_MAX_SCHEDULE_PER_PROCESS`` env var to multiprocess executor
1360
+ pool.
1343
1361
 
1344
1362
  The current workflow logic that split to process will be below diagram:
1345
1363
 
@@ -1356,7 +1374,7 @@ def workflow_runner(
1356
1374
  excluded: list[str] = excluded or []
1357
1375
 
1358
1376
  with ProcessPoolExecutor(
1359
- max_workers=int(os.getenv("WORKFLOW_APP_PROCESS_WORKER") or "2"),
1377
+ max_workers=config.max_schedule_process,
1360
1378
  ) as executor:
1361
1379
  futures: list[Future] = [
1362
1380
  executor.submit(
@@ -1367,7 +1385,7 @@ def workflow_runner(
1367
1385
  )
1368
1386
  for loader in batch(
1369
1387
  Loader.finds(Schedule, excluded=excluded),
1370
- n=int(os.getenv("WORKFLOW_APP_SCHEDULE_PER_PROCESS") or "100"),
1388
+ n=config.max_schedule_per_process,
1371
1389
  )
1372
1390
  ]
1373
1391
 
ddeutil/workflow/stage.py CHANGED
@@ -3,8 +3,8 @@
3
3
  # Licensed under the MIT License. See LICENSE in the project root for
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
- """Stage Model that use for getting stage data template from Job Model.
7
- The stage that handle the minimize task that run in some thread (same thread at
6
+ """Stage Model that use for getting stage data template from the Job Model.
7
+ The stage handle the minimize task that run in some thread (same thread at
8
8
  its job owner) that mean it is the lowest executor of a workflow workflow that
9
9
  can tracking logs.
10
10
 
@@ -12,11 +12,13 @@ can tracking logs.
12
12
  handle stage error on this stage model. I think stage model should have a lot of
13
13
  usecase and it does not worry when I want to create a new one.
14
14
 
15
- Execution --> Ok --> Result with 0
16
- --> Error --> Raise StageException
15
+ Execution --> Ok --> Result with 0
16
+ --> Error --> Raise StageException
17
+ --> Result with 1 (if env var was set)
17
18
 
18
- On the context I/O that pass to stage object at execute process. The execute
19
- method receive `{"params": {...}}` for mapping to template.
19
+ On the context I/O that pass to a stage object at execute process. The
20
+ execute method receives a `params={"params": {...}}` value for mapping to
21
+ template searching.
20
22
  """
21
23
  from __future__ import annotations
22
24
 
@@ -88,20 +90,28 @@ def handler_result(message: str | None = None) -> DecoratorResult:
88
90
  you force catching an output result with error message by specific
89
91
  environment variable,`WORKFLOW_CORE_STAGE_RAISE_ERROR`.
90
92
 
91
- Execution --> Ok --> Result with 0
93
+ Execution --> Ok --> Result
94
+ status: 0
95
+ context:
96
+ outputs: ...
92
97
  --> Error --> Raise StageException
93
- --> Result with 1 (if env var was set)
98
+ --> Result (if env var was set)
99
+ status: 1
100
+ context:
101
+ error: ...
102
+ error_message: ...
94
103
 
95
104
  On the last step, it will set the running ID on a return result object
96
105
  from current stage ID before release the final result.
97
106
 
98
107
  :param message: A message that want to add at prefix of exception statement.
108
+ :type message: str | None (Default=None)
99
109
  :rtype: Callable[P, Result]
100
110
  """
101
111
  # NOTE: The prefix message string that want to add on the first exception
102
112
  # message dialog.
103
113
  #
104
- # ... ValueError: {message}
114
+ # >>> ValueError: {message}
105
115
  # ... raise value error from the stage execution process.
106
116
  #
107
117
  message: str = message or ""
@@ -175,7 +185,7 @@ class BaseStage(BaseModel, ABC):
175
185
  )
176
186
 
177
187
  @model_validator(mode="after")
178
- def __prepare_running_id(self) -> Self:
188
+ def __prepare_running_id__(self) -> Self:
179
189
  """Prepare stage running ID that use default value of field and this
180
190
  method will validate name and id fields should not contain any template
181
191
  parameter (exclude matrix template).
@@ -235,7 +245,7 @@ class BaseStage(BaseModel, ABC):
235
245
  :param to: A context data that want to add output result.
236
246
  :rtype: DictData
237
247
  """
238
- if not (self.id or config.stage_default_id):
248
+ if self.id is None and not config.stage_default_id:
239
249
  logger.debug(
240
250
  f"({self.run_id}) [STAGE]: Output does not set because this "
241
251
  f"stage does not set ID or default stage ID config flag not be "
@@ -255,7 +265,7 @@ class BaseStage(BaseModel, ABC):
255
265
  )
256
266
 
257
267
  # NOTE: Set the output to that stage generated ID with ``outputs`` key.
258
- logger.debug(f"({self.run_id}) [STAGE]: Set outputs on: {_id}")
268
+ logger.debug(f"({self.run_id}) [STAGE]: Set outputs to {_id!r}")
259
269
  to["stages"][_id] = {"outputs": output}
260
270
  return to
261
271
 
@@ -299,6 +309,7 @@ class EmptyStage(BaseStage):
299
309
  sleep: float = Field(
300
310
  default=0,
301
311
  description="A second value to sleep before finish execution",
312
+ ge=0,
302
313
  )
303
314
 
304
315
  def execute(self, params: DictData) -> Result:
@@ -351,7 +362,7 @@ class BashStage(BaseStage):
351
362
  )
352
363
 
353
364
  @contextlib.contextmanager
354
- def __prepare_bash(self, bash: str, env: DictStr) -> Iterator[TupleStr]:
365
+ def prepare_bash(self, bash: str, env: DictStr) -> Iterator[TupleStr]:
355
366
  """Return context of prepared bash statement that want to execute. This
356
367
  step will write the `.sh` file before giving this file name to context.
357
368
  After that, it will auto delete this file automatic.
@@ -394,15 +405,12 @@ class BashStage(BaseStage):
394
405
  :rtype: Result
395
406
  """
396
407
  bash: str = param2template(dedent(self.bash), params)
397
- with self.__prepare_bash(
408
+ with self.prepare_bash(
398
409
  bash=bash, env=param2template(self.env, params)
399
410
  ) as sh:
400
411
  logger.info(f"({self.run_id}) [STAGE]: Shell-Execute: {sh}")
401
412
  rs: CompletedProcess = subprocess.run(
402
- sh,
403
- shell=False,
404
- capture_output=True,
405
- text=True,
413
+ sh, shell=False, capture_output=True, text=True
406
414
  )
407
415
  if rs.returncode > 0:
408
416
  # NOTE: Prepare stderr message that returning from subprocess.
@@ -419,8 +427,8 @@ class BashStage(BaseStage):
419
427
  status=0,
420
428
  context={
421
429
  "return_code": rs.returncode,
422
- "stdout": rs.stdout.rstrip("\n"),
423
- "stderr": rs.stderr.rstrip("\n"),
430
+ "stdout": rs.stdout.rstrip("\n") or None,
431
+ "stderr": rs.stderr.rstrip("\n") or None,
424
432
  },
425
433
  )
426
434
 
@@ -554,14 +562,14 @@ class HookStage(BaseStage):
554
562
  >>> stage = {
555
563
  ... "name": "Task stage execution",
556
564
  ... "uses": "tasks/function-name@tag-name",
557
- ... "args": {
558
- ... "FOO": "BAR",
559
- ... },
565
+ ... "args": {"FOO": "BAR"},
560
566
  ... }
561
567
  """
562
568
 
563
569
  uses: str = Field(
564
- description="A pointer that want to load function from registry.",
570
+ description=(
571
+ "A pointer that want to load function from the hook registry."
572
+ ),
565
573
  )
566
574
  args: DictData = Field(
567
575
  default_factory=dict,
@@ -622,10 +630,7 @@ class TriggerStage(BaseStage):
622
630
  >>> stage = {
623
631
  ... "name": "Trigger workflow stage execution",
624
632
  ... "trigger": 'workflow-name-for-loader',
625
- ... "params": {
626
- ... "run-date": "2024-08-01",
627
- ... "source": "src",
628
- ... },
633
+ ... "params": {"run-date": "2024-08-01", "source": "src"},
629
634
  ... }
630
635
  """
631
636