ddeutil-workflow 0.0.15__py3-none-any.whl → 0.0.17__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
@@ -4,6 +4,9 @@
4
4
  # license information.
5
5
  # ------------------------------------------------------------------------------
6
6
  """Job Model that use for keeping stages and node that running its stages.
7
+ The job handle the lineage of stages and location of execution of stages that
8
+ mean the job model able to define ``runs-on`` key that allow you to run this
9
+ job.
7
10
  """
8
11
  from __future__ import annotations
9
12
 
@@ -19,27 +22,20 @@ from concurrent.futures import (
19
22
  from functools import lru_cache
20
23
  from textwrap import dedent
21
24
  from threading import Event
22
- from typing import Optional
25
+ from typing import Optional, Union
23
26
 
24
27
  from ddeutil.core import freeze_args
25
28
  from pydantic import BaseModel, Field
26
29
  from pydantic.functional_validators import field_validator, model_validator
27
30
  from typing_extensions import Self
28
31
 
29
- from .__types import (
30
- DictData,
31
- DictStr,
32
- Matrix,
33
- MatrixExclude,
34
- MatrixInclude,
35
- TupleStr,
36
- )
32
+ from .__types import DictData, DictStr, Matrix, TupleStr
33
+ from .conf import config, get_logger
37
34
  from .exceptions import (
38
35
  JobException,
39
36
  StageException,
40
37
  UtilException,
41
38
  )
42
- from .log import get_logger
43
39
  from .stage import Stage
44
40
  from .utils import (
45
41
  Result,
@@ -51,6 +47,8 @@ from .utils import (
51
47
  )
52
48
 
53
49
  logger = get_logger("ddeutil.workflow")
50
+ MatrixInclude = list[dict[str, Union[str, int]]]
51
+ MatrixExclude = list[dict[str, Union[str, int]]]
54
52
 
55
53
 
56
54
  __all__: TupleStr = (
@@ -112,6 +110,7 @@ def make(
112
110
  all(inc.get(k) == v for k, v in m.items()) for m in [*final, *add]
113
111
  ):
114
112
  continue
113
+
115
114
  add.append(inc)
116
115
 
117
116
  # NOTE: Merge all matrix together.
@@ -262,7 +261,7 @@ class Job(BaseModel):
262
261
  )
263
262
 
264
263
  @model_validator(mode="before")
265
- def __prepare_keys(cls, values: DictData) -> DictData:
264
+ def __prepare_keys__(cls, values: DictData) -> DictData:
266
265
  """Rename key that use dash to underscore because Python does not
267
266
  support this character exist in any variable name.
268
267
 
@@ -273,12 +272,33 @@ class Job(BaseModel):
273
272
  return values
274
273
 
275
274
  @field_validator("desc", mode="after")
276
- def ___prepare_desc(cls, value: str) -> str:
277
- """Prepare description string that was created on a template."""
275
+ def ___prepare_desc__(cls, value: str) -> str:
276
+ """Prepare description string that was created on a template.
277
+
278
+ :rtype: str
279
+ """
278
280
  return dedent(value)
279
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
+
280
300
  @model_validator(mode="after")
281
- def __prepare_running_id(self) -> Self:
301
+ def __prepare_running_id_and_stage_name__(self) -> Self:
282
302
  """Prepare the job running ID.
283
303
 
284
304
  :rtype: Self
@@ -319,34 +339,44 @@ class Job(BaseModel):
319
339
  For example of setting output method, If you receive execute output
320
340
  and want to set on the `to` like;
321
341
 
322
- ... (i) output: {'strategy01': bar, 'strategy02': bar}
323
- ... (ii) to: {'jobs'}
342
+ ... (i) output: {'strategy-01': bar, 'strategy-02': bar}
343
+ ... (ii) to: {'jobs': {}}
324
344
 
325
345
  The result of the `to` variable will be;
326
346
 
327
347
  ... (iii) to: {
328
- 'jobs': {
348
+ 'jobs': {
349
+ '<job-id>': {
329
350
  'strategies': {
330
- 'strategy01': bar, 'strategy02': bar
351
+ 'strategy-01': bar,
352
+ 'strategy-02': bar
331
353
  }
332
354
  }
333
355
  }
356
+ }
334
357
 
335
358
  :param output: An output context.
336
359
  :param to: A context data that want to add output result.
337
360
  :rtype: DictData
338
361
  """
339
- if self.id is None:
362
+ if self.id is None and not config.job_default_id:
340
363
  raise JobException(
341
- "This job do not set the ID before setting output."
364
+ "This job do not set the ID before setting execution output."
342
365
  )
343
366
 
344
- to["jobs"][self.id] = (
367
+ # NOTE: Create jobs key to receive an output from the job execution.
368
+ if "jobs" not in to:
369
+ to["jobs"] = {}
370
+
371
+ # NOTE: If the job ID did not set, it will use index of jobs key
372
+ # instead.
373
+ _id: str = self.id or str(len(to["jobs"]) + 1)
374
+
375
+ logger.debug(f"({self.run_id}) [JOB]: Set outputs on: {_id}")
376
+ to["jobs"][_id] = (
345
377
  {"strategies": output}
346
378
  if self.strategy.is_set()
347
- # NOTE:
348
- # This is the best way to get single key from dict.
349
- else output[next(iter(output))]
379
+ else output.get(next(iter(output), "DUMMY"), {})
350
380
  )
351
381
  return to
352
382
 
@@ -356,7 +386,6 @@ class Job(BaseModel):
356
386
  params: DictData,
357
387
  *,
358
388
  event: Event | None = None,
359
- raise_error: bool = True,
360
389
  ) -> Result:
361
390
  """Job Strategy execution with passing dynamic parameters from the
362
391
  workflow execution to strategy matrix.
@@ -365,25 +394,27 @@ class Job(BaseModel):
365
394
  It different with ``self.execute`` because this method run only one
366
395
  strategy and return with context of this strategy data.
367
396
 
368
- :raise JobException: If it has any error from StageException or
369
- UtilException.
397
+ :raise JobException: If it has any error from ``StageException`` or
398
+ ``UtilException``.
370
399
 
371
400
  :param strategy: A metrix strategy value.
372
401
  :param params: A dynamic parameters.
373
402
  :param event: An manger event that pass to the PoolThreadExecutor.
374
- :param raise_error: A flag that raise error instead catching to result
375
- if it get exception from stage execution.
403
+
376
404
  :rtype: Result
377
405
  """
378
406
  strategy_id: str = gen_id(strategy)
379
407
 
380
- # 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
381
411
  # of params. So, the context value will have structure like;
382
412
  #
383
413
  # {
384
414
  # "params": { ... }, <== Current input params
385
415
  # "jobs": { ... }, <== Current input params
386
416
  # "matrix": { ... } <== Current strategy value
417
+ # "stages": { ... } <== Catching stage outputs
387
418
  # }
388
419
  #
389
420
  context: DictData = copy.deepcopy(params)
@@ -395,14 +426,14 @@ class Job(BaseModel):
395
426
  # IMPORTANT: Change any stage running IDs to this job running ID.
396
427
  stage: Stage = stage.get_running_id(self.run_id)
397
428
 
398
- _st_name: str = stage.id or stage.name
429
+ name: str = stage.id or stage.name
399
430
 
400
431
  if stage.is_skipped(params=context):
401
- logger.info(f"({self.run_id}) [JOB]: Skip stage: {_st_name!r}")
432
+ logger.info(f"({self.run_id}) [JOB]: Skip stage: {name!r}")
402
433
  continue
403
434
 
404
435
  logger.info(
405
- f"({self.run_id}) [JOB]: Start execute the stage: {_st_name!r}"
436
+ f"({self.run_id}) [JOB]: Start execute the stage: {name!r}"
406
437
  )
407
438
 
408
439
  # NOTE: Logging a matrix that pass on this stage execution.
@@ -422,20 +453,20 @@ class Job(BaseModel):
422
453
  # ---
423
454
  # "stages": filter_func(context.pop("stages", {})),
424
455
  "stages": context.pop("stages", {}),
425
- # NOTE: Set the error keys.
426
456
  "error": JobException(
427
- "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."
428
463
  ),
429
- "error_message": {
430
- "message": (
431
- "Process Event stopped before execution"
432
- ),
433
- },
434
464
  },
435
465
  },
436
466
  )
437
467
 
438
- # NOTE:
468
+ # PARAGRAPH:
469
+ #
439
470
  # I do not use below syntax because `params` dict be the
440
471
  # reference memory pointer and it was changed when I action
441
472
  # anything like update or re-construct this.
@@ -461,16 +492,25 @@ class Job(BaseModel):
461
492
  logger.error(
462
493
  f"({self.run_id}) [JOB]: {err.__class__.__name__}: {err}"
463
494
  )
464
- if raise_error:
495
+ if config.job_raise_error:
465
496
  raise JobException(
466
497
  f"Get stage execution error: {err.__class__.__name__}: "
467
498
  f"{err}"
468
499
  ) from None
469
- else:
470
- 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
+ )
471
511
 
472
- # NOTE: Remove new stage object that was created from
473
- # ``get_running_id`` method.
512
+ # NOTE: Remove the current stage object that was created from
513
+ # ``get_running_id`` method for saving memory.
474
514
  del stage
475
515
 
476
516
  return Result(
@@ -491,15 +531,18 @@ class Job(BaseModel):
491
531
  :param params: An input parameters that use on job execution.
492
532
  :rtype: Result
493
533
  """
494
- context: DictData = {}
534
+
535
+ # NOTE: I use this condition because this method allow passing empty
536
+ # params and I do not want to create new dict object.
495
537
  params: DictData = {} if params is None else params
538
+ context: DictData = {}
496
539
 
497
540
  # NOTE: Normal Job execution without parallel strategy.
498
541
  if (not self.strategy.is_set()) or self.strategy.max_parallel == 1:
499
542
  for strategy in self.strategy.make():
500
543
  rs: Result = self.execute_strategy(
501
544
  strategy=strategy,
502
- params=copy.deepcopy(params),
545
+ params=params,
503
546
  )
504
547
  context.update(rs.context)
505
548
  return Result(
@@ -507,11 +550,17 @@ class Job(BaseModel):
507
550
  context=context,
508
551
  )
509
552
 
510
- # NOTE: Create event for cancel executor stop running.
553
+ # NOTE: Create event for cancel executor by trigger stop running event.
511
554
  event: Event = Event()
512
555
 
556
+ print("Job Run Fail-Fast:", self.strategy.fail_fast)
557
+
558
+ # IMPORTANT: Start running strategy execution by multithreading because
559
+ # it will running by strategy values without waiting previous
560
+ # execution.
513
561
  with ThreadPoolExecutor(
514
- max_workers=self.strategy.max_parallel
562
+ max_workers=self.strategy.max_parallel,
563
+ thread_name_prefix="job_strategy_exec_",
515
564
  ) as executor:
516
565
  futures: list[Future] = [
517
566
  executor.submit(
@@ -566,30 +615,34 @@ class Job(BaseModel):
566
615
  )
567
616
  logger.debug(f"({self.run_id}) [JOB]: Strategy is set Fail Fast{nd}")
568
617
 
569
- # NOTE: Stop all running tasks with setting the event manager and cancel
618
+ # NOTE:
619
+ # Stop all running tasks with setting the event manager and cancel
570
620
  # any scheduled tasks.
621
+ #
571
622
  if len(done) != len(futures):
572
623
  event.set()
573
- for future in futures:
624
+ for future in not_done:
574
625
  future.cancel()
575
626
 
576
- del future
577
-
627
+ future: Future
578
628
  for future in done:
579
- if future.exception():
580
- status = 1
629
+ if err := future.exception():
630
+ status: int = 1
581
631
  logger.error(
582
632
  f"({self.run_id}) [JOB]: One stage failed with: "
583
633
  f"{future.exception()}, shutting down this future."
584
634
  )
585
- elif future.cancelled():
635
+ context.update(
636
+ {
637
+ "error": err,
638
+ "error_message": f"{err.__class__.__name__}: {err}",
639
+ },
640
+ )
586
641
  continue
587
642
 
588
643
  # NOTE: Update the result context to main job context.
589
644
  context.update(future.result(timeout=result_timeout).context)
590
645
 
591
- del future
592
-
593
646
  return rs_final.catch(status=status, context=context)
594
647
 
595
648
  def __catch_all_completed(
@@ -614,7 +667,7 @@ class Job(BaseModel):
614
667
  for future in as_completed(futures, timeout=timeout):
615
668
  try:
616
669
  context.update(future.result(timeout=result_timeout).context)
617
- except TimeoutError:
670
+ except TimeoutError: # pragma: no cov
618
671
  status = 1
619
672
  logger.warning(
620
673
  f"({self.run_id}) [JOB]: Task is hanging. Attempting to "
@@ -636,6 +689,10 @@ class Job(BaseModel):
636
689
  f"fail-fast does not set;\n{err.__class__.__name__}:\n\t"
637
690
  f"{err}"
638
691
  )
639
- finally:
640
- del future
692
+ context.update(
693
+ {
694
+ "error": err,
695
+ "error_message": f"{err.__class__.__name__}: {err}",
696
+ },
697
+ )
641
698
  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
- from .cron import WEEKDAYS, CronJob, CronJobYear, CronRunner
19
- from .utils import Loader
19
+ from .conf import Loader
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):
@@ -6,16 +6,14 @@
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, get_logger
17
16
  from .cron import CronJob
18
- from .log import get_logger
19
17
 
20
18
  logger = get_logger("ddeutil.workflow")
21
19
 
@@ -24,9 +22,7 @@ def get_cronjob_delta(cron: str) -> float:
24
22
  """This function returns the time delta between now and the next cron
25
23
  execution time.
26
24
  """
27
- now: datetime = datetime.now(
28
- tz=ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
29
- )
25
+ now: datetime = datetime.now(tz=config.tz)
30
26
  cron = CronJob(cron)
31
27
  return (cron.schedule(now).next - now).total_seconds()
32
28
 
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,9 @@ from pydantic import BaseModel
18
16
 
19
17
  from . import Workflow
20
18
  from .__types import DictData
21
- from .log import get_logger
19
+ from .conf import Loader, config, get_logger
22
20
  from .scheduler import Schedule
23
- from .utils import Loader, Result
21
+ from .utils import Result
24
22
 
25
23
  logger = get_logger("ddeutil.workflow")
26
24
  workflow = APIRouter(
@@ -87,12 +85,7 @@ async def execute_workflow(name: str, payload: ExecutePayload) -> DictData:
87
85
  # NOTE: Start execute manually
88
86
  rs: Result = wf.execute(params=payload.params)
89
87
 
90
- return rs.model_dump(
91
- by_alias=True,
92
- exclude_none=True,
93
- exclude_unset=True,
94
- exclude_defaults=True,
95
- )
88
+ return dict(rs)
96
89
 
97
90
 
98
91
  @workflow.get("/{name}/logs")
@@ -172,8 +165,7 @@ async def add_deploy_scheduler(request: Request, name: str):
172
165
 
173
166
  request.state.scheduler.append(name)
174
167
 
175
- tz: ZoneInfo = ZoneInfo(os.getenv("WORKFLOW_CORE_TIMEZONE", "UTC"))
176
- start_date: datetime = datetime.now(tz=tz)
168
+ start_date: datetime = datetime.now(tz=config.tz)
177
169
  start_date_waiting: datetime = (start_date + timedelta(minutes=1)).replace(
178
170
  second=0, microsecond=0
179
171
  )