ddeutil-workflow 0.0.6__py3-none-any.whl → 0.0.7__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.
@@ -7,20 +7,41 @@ from __future__ import annotations
7
7
 
8
8
  import copy
9
9
  import logging
10
+ import os
10
11
  import time
12
+ from concurrent.futures import (
13
+ FIRST_EXCEPTION,
14
+ Future,
15
+ ProcessPoolExecutor,
16
+ ThreadPoolExecutor,
17
+ as_completed,
18
+ wait,
19
+ )
20
+ from datetime import datetime
21
+ from multiprocessing import Event, Manager
22
+ from pickle import PickleError
11
23
  from queue import Queue
12
24
  from typing import Optional
25
+ from zoneinfo import ZoneInfo
13
26
 
14
27
  from pydantic import BaseModel, Field
15
28
  from pydantic.functional_validators import model_validator
16
29
  from typing_extensions import Self
17
30
 
18
31
  from .__types import DictData, DictStr, Matrix, MatrixExclude, MatrixInclude
19
- from .exceptions import JobException, PipelineException
32
+ from .exceptions import JobException, PipelineException, StageException
20
33
  from .loader import Loader
21
34
  from .on import On
35
+ from .scheduler import CronRunner
22
36
  from .stage import Stage
23
- from .utils import Param, Result, cross_product, dash2underscore, gen_id
37
+ from .utils import (
38
+ Param,
39
+ Result,
40
+ cross_product,
41
+ dash2underscore,
42
+ gen_id,
43
+ get_diff_sec,
44
+ )
24
45
 
25
46
 
26
47
  class Strategy(BaseModel):
@@ -29,6 +50,8 @@ class Strategy(BaseModel):
29
50
 
30
51
  Data Validate:
31
52
  >>> strategy = {
53
+ ... 'max-parallel': 1,
54
+ ... 'fail-fast': False,
32
55
  ... 'matrix': {
33
56
  ... 'first': [1, 2, 3],
34
57
  ... 'second': ['foo', 'bar']
@@ -39,7 +62,7 @@ class Strategy(BaseModel):
39
62
  """
40
63
 
41
64
  fail_fast: bool = Field(default=False)
42
- max_parallel: int = Field(default=-1)
65
+ max_parallel: int = Field(default=1, gt=0)
43
66
  matrix: Matrix = Field(default_factory=dict)
44
67
  include: MatrixInclude = Field(
45
68
  default_factory=list,
@@ -164,11 +187,47 @@ class Job(BaseModel):
164
187
 
165
188
  return output[next(iter(output))]
166
189
 
167
- def strategy_execute(self, strategy: DictData, params: DictData) -> Result:
168
- context: DictData = {}
169
- context.update(params)
190
+ def strategy_execute(
191
+ self,
192
+ strategy: DictData,
193
+ params: DictData,
194
+ *,
195
+ event: Event | None = None,
196
+ ) -> Result:
197
+ """Strategy execution with passing dynamic parameters from the pipeline
198
+ stage execution.
199
+
200
+ :param strategy:
201
+ :param params:
202
+ :param event: An manger event that pass to the PoolThreadExecutor.
203
+ :rtype: Result
204
+ """
205
+ _stop_rs: Result = Result(
206
+ status=1,
207
+ context={
208
+ gen_id(strategy): {
209
+ "matrix": strategy,
210
+ "stages": {},
211
+ "error": "Event stopped",
212
+ },
213
+ },
214
+ )
215
+ if event and event.is_set():
216
+ return _stop_rs
217
+
218
+ # NOTE: Create strategy execution context and update a matrix and copied
219
+ # of params. So, the context value will have structure like;
220
+ # ---
221
+ # {
222
+ # "params": { ... }, <== Current input params
223
+ # "jobs": { ... }, <== Current input params
224
+ # "matrix": { ... } <== Current strategy value
225
+ # }
226
+ #
227
+ context: DictData = params
170
228
  context.update({"matrix": strategy})
171
229
 
230
+ # IMPORTANT: The stage execution only run sequentially one-by-one.
172
231
  for stage in self.stages:
173
232
  _st_name: str = stage.id or stage.name
174
233
 
@@ -177,6 +236,29 @@ class Job(BaseModel):
177
236
  continue
178
237
  logging.info(f"[JOB]: Start execute the stage: {_st_name!r}")
179
238
 
239
+ # NOTE: Logging a matrix that pass on this stage execution.
240
+ if strategy:
241
+ logging.info(f"[...]: Matrix: {strategy}")
242
+
243
+ # NOTE:
244
+ # I do not use below syntax because `params` dict be the
245
+ # reference memory pointer and it was changed when I action
246
+ # anything like update or re-construct this.
247
+ #
248
+ # ... params |= stage.execute(params=params)
249
+ #
250
+ # This step will add the stage result to ``stages`` key in
251
+ # that stage id. It will have structure like;
252
+ # ---
253
+ # {
254
+ # "params": { ... },
255
+ # "jobs": { ... },
256
+ # "matrix": { ... },
257
+ # "stages": { { "stage-id-1": ... }, ... }
258
+ # }
259
+ #
260
+ if event and event.is_set():
261
+ return _stop_rs
180
262
  rs: Result = stage.execute(params=context)
181
263
  if rs.status == 0:
182
264
  stage.set_outputs(rs.context, params=context)
@@ -185,6 +267,8 @@ class Job(BaseModel):
185
267
  f"Getting status does not equal zero on stage: "
186
268
  f"{stage.name}."
187
269
  )
270
+ # TODO: Filter and warning if it pass any objects to context between
271
+ # strategy job executor like function, etc.
188
272
  return Result(
189
273
  status=0,
190
274
  context={
@@ -204,71 +288,104 @@ class Job(BaseModel):
204
288
  :rtype: Result
205
289
  """
206
290
  strategy_context: DictData = {}
207
- for strategy in self.strategy.make():
291
+ rs = Result(context=strategy_context)
208
292
 
209
- # NOTE: Create strategy context and update matrix and params to this
210
- # context. So, the context will have structure like;
211
- # ---
212
- # {
213
- # "params": { ... }, <== Current input params
214
- # "jobs": { ... },
215
- # "matrix": { ... } <== Current strategy value
216
- # }
217
- #
218
- context: DictData = {}
219
- context.update(params)
220
- context.update({"matrix": strategy})
293
+ if self.strategy.max_parallel == 1:
294
+ for strategy in self.strategy.make():
295
+ rs: Result = self.strategy_execute(
296
+ strategy, params=copy.deepcopy(params)
297
+ )
298
+ strategy_context.update(rs.context)
299
+ return rs
221
300
 
222
- # TODO: we should add option for ``wait_as_complete`` for release
223
- # a stage execution to run on background (multi-thread).
224
- # ---
225
- # >>> from concurrency
226
- #
227
- # IMPORTANT: The stage execution only run sequentially one-by-one.
228
- for stage in self.stages:
229
- _st_name: str = stage.id or stage.name
230
-
231
- if stage.is_skip(params=context):
232
- logging.info(f"[JOB]: Skip the stage: {_st_name!r}")
233
- continue
234
- logging.info(f"[JOB]: Start execute the stage: {_st_name!r}")
235
-
236
- # NOTE: Logging a matrix that pass on this stage execution.
237
- if strategy:
238
- logging.info(f"[...]: Matrix: {strategy}")
239
-
240
- # NOTE:
241
- # I do not use below syntax because `params` dict be the
242
- # reference memory pointer and it was changed when I action
243
- # anything like update or re-construct this.
244
- #
245
- # ... params |= stage.execute(params=params)
246
- #
247
- # This step will add the stage result to ``stages`` key in
248
- # that stage id. It will have structure like;
249
- # ---
250
- # {
251
- # "params": { ... },
252
- # "jobs": { ... },
253
- # "matrix": { ... },
254
- # "stages": { { "stage-id-1": ... }, ... }
255
- # }
256
- #
257
- rs: Result = stage.execute(params=context)
258
- if rs.status == 0:
259
- stage.set_outputs(rs.context, params=context)
260
- else:
261
- raise JobException(
262
- f"Getting status does not equal zero on stage: "
263
- f"{stage.name}."
301
+ # FIXME: (WF001) I got error that raise when use
302
+ # ``ProcessPoolExecutor``;
303
+ # ---
304
+ # _pickle.PicklingError: Can't pickle
305
+ # <function ??? at 0x000001F0BE80F160>: attribute lookup ???
306
+ # on ddeutil.workflow.stage failed
307
+ #
308
+ with Manager() as manager:
309
+ event: Event = manager.Event()
310
+
311
+ with ProcessPoolExecutor(
312
+ max_workers=self.strategy.max_parallel
313
+ ) as pool:
314
+ pool_result: list[Future] = [
315
+ pool.submit(
316
+ self.strategy_execute,
317
+ st,
318
+ params=copy.deepcopy(params),
319
+ event=event,
264
320
  )
265
-
266
- strategy_context[gen_id(strategy)] = {
267
- "matrix": strategy,
268
- "stages": context.pop("stages", {}),
269
- }
270
-
271
- return Result(status=0, context=strategy_context)
321
+ for st in self.strategy.make()
322
+ ]
323
+ if self.strategy.fail_fast:
324
+
325
+ # NOTE: Get results from a collection of tasks with a
326
+ # timeout that has the first exception.
327
+ done, not_done = wait(
328
+ pool_result, timeout=60, return_when=FIRST_EXCEPTION
329
+ )
330
+ nd: str = (
331
+ f", the strategies do not run is {not_done}"
332
+ if not_done
333
+ else ""
334
+ )
335
+ logging.warning(f"[JOB]: Strategy is set Fail Fast{nd}")
336
+
337
+ # NOTE: Stop all running tasks
338
+ event.set()
339
+
340
+ # NOTE: Cancel any scheduled tasks
341
+ for future in pool_result:
342
+ future.cancel()
343
+
344
+ rs.status = 0
345
+ for f in done:
346
+ if f.exception():
347
+ rs.status = 1
348
+ logging.error(
349
+ f"One task failed with: {f.exception()}, "
350
+ f"shutting down"
351
+ )
352
+ elif f.cancelled():
353
+ continue
354
+ else:
355
+ rs: Result = f.result(timeout=60)
356
+ strategy_context.update(rs.context)
357
+ rs.context = strategy_context
358
+ return rs
359
+
360
+ for pool_rs in as_completed(pool_result):
361
+ try:
362
+ rs: Result = pool_rs.result(timeout=60)
363
+ strategy_context.update(rs.context)
364
+ except PickleError as err:
365
+ # NOTE: I do not want to fix this issue because it does
366
+ # not make sense and over-engineering with this bug
367
+ # fix process.
368
+ raise JobException(
369
+ f"PyStage that create object on locals does use "
370
+ f"parallel in strategy;\n\t{err}"
371
+ ) from None
372
+ except TimeoutError:
373
+ rs.status = 1
374
+ logging.warning("Task is hanging. Attempting to kill.")
375
+ pool_rs.cancel()
376
+ if not pool_rs.cancelled():
377
+ logging.warning("Failed to cancel the task.")
378
+ else:
379
+ logging.warning("Task canceled successfully.")
380
+ except StageException as err:
381
+ rs.status = 1
382
+ logging.warning(
383
+ f"Get stage exception with fail-fast does not set;"
384
+ f"\n\t{err}"
385
+ )
386
+ rs.status = 0
387
+ rs.context = strategy_context
388
+ return rs
272
389
 
273
390
 
274
391
  class Pipeline(BaseModel):
@@ -356,6 +473,18 @@ class Pipeline(BaseModel):
356
473
  }
357
474
  return values
358
475
 
476
+ @model_validator(mode="after")
477
+ def __validate_jobs_need(self):
478
+ for job in self.jobs:
479
+ if not_exist := [
480
+ need for need in self.jobs[job].needs if need not in self.jobs
481
+ ]:
482
+ raise PipelineException(
483
+ f"This needed jobs: {not_exist} do not exist in this "
484
+ f"pipeline."
485
+ )
486
+ return self
487
+
359
488
  def job(self, name: str) -> Job:
360
489
  """Return Job model that exists on this pipeline.
361
490
 
@@ -375,6 +504,7 @@ class Pipeline(BaseModel):
375
504
  job execution.
376
505
 
377
506
  :param params: A parameter mapping that receive from pipeline execution.
507
+ :rtype: DictData
378
508
  """
379
509
  # VALIDATE: Incoming params should have keys that set on this pipeline.
380
510
  if check_key := tuple(
@@ -382,7 +512,7 @@ class Pipeline(BaseModel):
382
512
  for k in self.params
383
513
  if (k not in params and self.params[k].required)
384
514
  ):
385
- raise ValueError(
515
+ raise PipelineException(
386
516
  f"Required Param on this pipeline setting does not set: "
387
517
  f"{', '.join(check_key)}."
388
518
  )
@@ -400,6 +530,102 @@ class Pipeline(BaseModel):
400
530
  "jobs": {},
401
531
  }
402
532
 
533
+ def release(
534
+ self,
535
+ on: On,
536
+ params: DictData | None = None,
537
+ *,
538
+ waiting_sec: int = 600,
539
+ sleep_interval: int = 10,
540
+ ) -> str:
541
+ """Start running pipeline with the on schedule in period of 30 minutes.
542
+ That mean it will still running at background 30 minutes until the
543
+ schedule matching with its time.
544
+ """
545
+ params: DictData = params or {}
546
+ logging.info(f"[CORE] Start release: {self.name!r} : {on.cronjob}")
547
+
548
+ gen: CronRunner = on.generate(datetime.now())
549
+ tz: ZoneInfo = gen.tz
550
+ next_running_time: datetime = gen.next
551
+
552
+ if get_diff_sec(next_running_time, tz=tz) < waiting_sec:
553
+ logging.debug(
554
+ f"[CORE]: {self.name} closely to run >> "
555
+ f"{next_running_time:%Y-%m-%d %H:%M:%S}"
556
+ )
557
+
558
+ # NOTE: Release when the time is nearly to schedule time.
559
+ while (duration := get_diff_sec(next_running_time, tz=tz)) > 15:
560
+ time.sleep(sleep_interval)
561
+ logging.debug(
562
+ f"[CORE]: {self.name!r} : Sleep until: {duration}"
563
+ )
564
+
565
+ time.sleep(1)
566
+ rs: Result = self.execute(params=params)
567
+ logging.debug(f"{rs.context}")
568
+
569
+ return f"[CORE]: Start Execute: {self.name}"
570
+ return f"[CORE]: {self.name} does not closely to run yet."
571
+
572
+ def poke(self, params: DictData | None = None):
573
+ """Poke pipeline threading task for executing with its schedules that
574
+ was set on the `on`.
575
+ """
576
+ params: DictData = params or {}
577
+ logging.info(
578
+ f"[CORE]: Start Poking: {self.name!r} :"
579
+ f"{gen_id(self.name, unique=True)}"
580
+ )
581
+ results = []
582
+ with ThreadPoolExecutor(
583
+ max_workers=int(
584
+ os.getenv("WORKFLOW_CORE_MAX_PIPELINE_POKING", "4")
585
+ ),
586
+ ) as executor:
587
+ futures: list[Future] = [
588
+ executor.submit(
589
+ self.release,
590
+ on,
591
+ params=params,
592
+ )
593
+ for on in self.on
594
+ ]
595
+ for future in as_completed(futures):
596
+ rs = future.result()
597
+ logging.info(rs)
598
+ results.append(rs)
599
+ return results
600
+
601
+ def job_execute(
602
+ self,
603
+ job: str,
604
+ params: DictData,
605
+ ):
606
+ """Job Executor that use on pipeline executor.
607
+ :param job: A job ID that want to execute.
608
+ :param params: A params that was parameterized from pipeline execution.
609
+ """
610
+ # VALIDATE: check a job ID that exists in this pipeline or not.
611
+ if job not in self.jobs:
612
+ raise PipelineException(
613
+ f"The job ID: {job} does not exists on {self.name!r} pipeline."
614
+ )
615
+
616
+ job_obj: Job = self.jobs[job]
617
+
618
+ rs: Result = job_obj.execute(params=params)
619
+ if rs.status != 0:
620
+ logging.warning(
621
+ f"Getting status does not equal zero on job: {job}."
622
+ )
623
+ return Result(
624
+ status=1, context={job: job_obj.set_outputs(rs.context)}
625
+ )
626
+
627
+ return Result(status=0, context={job: job_obj.set_outputs(rs.context)})
628
+
403
629
  def execute(
404
630
  self,
405
631
  params: DictData | None = None,
@@ -430,7 +656,7 @@ class Pipeline(BaseModel):
430
656
 
431
657
  """
432
658
  logging.info(
433
- f"[CORE]: Start Pipeline {self.name}:"
659
+ f"[CORE]: Start Execute: {self.name}:"
434
660
  f"{gen_id(self.name, unique=True)}"
435
661
  )
436
662
  params: DictData = params or {}
@@ -452,42 +678,52 @@ class Pipeline(BaseModel):
452
678
  # NOTE: Create result context that will pass this context to any
453
679
  # execution dependency.
454
680
  rs: Result = Result(context=self.parameterize(params))
455
-
456
- # IMPORTANT: The job execution can run parallel and waiting by needed.
457
- while not jq.empty() and (
458
- not_time_out_flag := ((time.monotonic() - ts) < timeout)
459
- ):
460
- job_id: str = jq.get()
461
- logging.info(f"[PIPELINE]: Start execute the job: {job_id!r}")
462
- job: Job = self.jobs[job_id]
463
-
464
- # TODO: Condition on ``needs`` of this job was set. It should create
465
- # multithreading process on this step.
466
- # But, I don't know how to handle changes params between each job
467
- # execution while its use them together.
468
- # ---
469
- # >>> import multiprocessing
470
- # >>> with multiprocessing.Pool(processes=3) as pool:
471
- # ... results = pool.starmap(merge_names, ('', '', ...))
472
- # ---
473
- # This case we use multi-process because I want to split usage of
474
- # data in this level, that mean the data that push to parallel job
475
- # should not use across another job.
476
- #
477
- if any(rs.context["jobs"].get(need) for need in job.needs):
478
- jq.put(job_id)
479
-
480
- # NOTE: copy current the result context for reference other job
481
- # context.
482
- job_context: DictData = copy.deepcopy(rs.context)
483
- job_rs: Result = job.execute(params=job_context)
484
- if job_rs.status == 0:
485
- # NOTE: Receive output of job execution.
486
- rs.context["jobs"][job_id] = job.set_outputs(job_rs.context)
487
- else:
488
- raise PipelineException(
489
- f"Getting status does not equal zero on job: {job_id}."
681
+ if (
682
+ worker := int(os.getenv("WORKFLOW_CORE_MAX_JOB_PARALLEL", "1"))
683
+ ) > 1:
684
+ # IMPORTANT: The job execution can run parallel and waiting by
685
+ # needed.
686
+ with ThreadPoolExecutor(max_workers=worker) as executor:
687
+ futures: list[Future] = []
688
+ while not jq.empty() and (
689
+ not_time_out_flag := ((time.monotonic() - ts) < timeout)
690
+ ):
691
+ job_id: str = jq.get()
692
+ logging.info(
693
+ f"[PIPELINE]: Start execute the job: {job_id!r}"
694
+ )
695
+ job: Job = self.jobs[job_id]
696
+ if any(
697
+ need not in rs.context["jobs"] for need in job.needs
698
+ ):
699
+ jq.put(job_id)
700
+ futures.append(
701
+ executor.submit(
702
+ self.job_execute,
703
+ job_id,
704
+ params=copy.deepcopy(rs.context),
705
+ ),
706
+ )
707
+ for future in as_completed(futures):
708
+ job_rs: Result = future.result(timeout=20)
709
+ rs.context["jobs"].update(job_rs.context)
710
+ else:
711
+ logging.info(
712
+ f"[CORE]: Run {self.name} with non-threading job executor"
713
+ )
714
+ while not jq.empty() and (
715
+ not_time_out_flag := ((time.monotonic() - ts) < timeout)
716
+ ):
717
+ job_id: str = jq.get()
718
+ logging.info(f"[PIPELINE]: Start execute the job: {job_id!r}")
719
+ job: Job = self.jobs[job_id]
720
+ if any(need not in rs.context["jobs"] for need in job.needs):
721
+ jq.put(job_id)
722
+
723
+ job_rs = self.job_execute(
724
+ job_id, params=copy.deepcopy(rs.context)
490
725
  )
726
+ rs.context["jobs"].update(job_rs.context)
491
727
 
492
728
  if not not_time_out_flag:
493
729
  logging.warning("Execution of pipeline was time out")
@@ -0,0 +1,134 @@
1
+ # ------------------------------------------------------------------------------
2
+ # Copyright (c) 2023 Priyanshu Panwar. All rights reserved.
3
+ # Licensed under the MIT License.
4
+ # This code refs from: https://github.com/priyanshu-panwar/fastapi-utilities
5
+ # ------------------------------------------------------------------------------
6
+ import asyncio
7
+ import logging
8
+ from asyncio import ensure_future
9
+ from datetime import datetime
10
+ from functools import wraps
11
+
12
+ from croniter import croniter
13
+ from starlette.concurrency import run_in_threadpool
14
+
15
+
16
+ def get_delta(cron: str):
17
+ """This function returns the time delta between now and the next cron
18
+ execution time.
19
+ """
20
+ now: datetime = datetime.now()
21
+ cron = croniter(cron, now)
22
+ return (cron.get_next(datetime) - now).total_seconds()
23
+
24
+
25
+ def repeat_at(
26
+ *,
27
+ cron: str,
28
+ logger: logging.Logger = None,
29
+ raise_exceptions: bool = False,
30
+ max_repetitions: int = None,
31
+ ):
32
+ """This function returns a decorator that makes a function execute
33
+ periodically as per the cron expression provided.
34
+
35
+ :param cron: str
36
+ Cron-style string for periodic execution, eg. '0 0 * * *' every midnight
37
+ :param logger: logging.Logger (default None)
38
+ Logger object to log exceptions
39
+ :param raise_exceptions: bool (default False)
40
+ Whether to raise exceptions or log them
41
+ :param max_repetitions: int (default None)
42
+ Maximum number of times to repeat the function. If None, repeat
43
+ indefinitely.
44
+
45
+ """
46
+
47
+ def decorator(func):
48
+ is_coroutine = asyncio.iscoroutinefunction(func)
49
+
50
+ @wraps(func)
51
+ def wrapper(*_args, **_kwargs):
52
+ repititions = 0
53
+ if not croniter.is_valid(cron):
54
+ raise ValueError("Invalid cron expression")
55
+
56
+ async def loop(*args, **kwargs):
57
+ nonlocal repititions
58
+ while max_repetitions is None or repititions < max_repetitions:
59
+ try:
60
+ sleepTime = get_delta(cron)
61
+ await asyncio.sleep(sleepTime)
62
+ if is_coroutine:
63
+ await func(*args, **kwargs)
64
+ else:
65
+ await run_in_threadpool(func, *args, **kwargs)
66
+ except Exception as e:
67
+ if logger is not None:
68
+ logger.exception(e)
69
+ if raise_exceptions:
70
+ raise e
71
+ repititions += 1
72
+
73
+ ensure_future(loop(*_args, **_kwargs))
74
+
75
+ return wrapper
76
+
77
+ return decorator
78
+
79
+
80
+ def repeat_every(
81
+ *,
82
+ seconds: float,
83
+ wait_first: bool = False,
84
+ logger: logging.Logger = None,
85
+ raise_exceptions: bool = False,
86
+ max_repetitions: int = None,
87
+ ):
88
+ """This function returns a decorator that schedules a function to execute
89
+ periodically after every `seconds` seconds.
90
+
91
+ :param seconds: float
92
+ The number of seconds to wait before executing the function again.
93
+ :param wait_first: bool (default False)
94
+ Whether to wait `seconds` seconds before executing the function for the
95
+ first time.
96
+ :param logger: logging.Logger (default None)
97
+ The logger to use for logging exceptions.
98
+ :param raise_exceptions: bool (default False)
99
+ Whether to raise exceptions instead of logging them.
100
+ :param max_repetitions: int (default None)
101
+ The maximum number of times to repeat the function. If None, the
102
+ function will repeat indefinitely.
103
+ """
104
+
105
+ def decorator(func):
106
+ is_coroutine = asyncio.iscoroutinefunction(func)
107
+
108
+ @wraps(func)
109
+ async def wrapper(*_args, **_kwargs):
110
+ repetitions = 0
111
+
112
+ async def loop(*args, **kwargs):
113
+ nonlocal repetitions
114
+ if wait_first:
115
+ await asyncio.sleep(seconds)
116
+ while max_repetitions is None or repetitions < max_repetitions:
117
+ try:
118
+ if is_coroutine:
119
+ await func(*args, **kwargs)
120
+ else:
121
+ await run_in_threadpool(func, *args, **kwargs)
122
+ except Exception as e:
123
+ if logger is not None:
124
+ logger.exception(e)
125
+ if raise_exceptions:
126
+ raise e
127
+ repetitions += 1
128
+ await asyncio.sleep(seconds)
129
+
130
+ ensure_future(loop(*_args, **_kwargs))
131
+
132
+ return wrapper
133
+
134
+ return decorator