Skip to content

Commit 7f86d4d

Browse files
🚀cron jobs with repeat_at (#3)
* 🚀cron jobs with `repeat_at` * 🧪Test Cases updated * added `cronitier` to requirements * 🧪Test Cases added
1 parent eed50b9 commit 7f86d4d

File tree

10 files changed

+255
-4
lines changed

10 files changed

+255
-4
lines changed

README.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,26 @@
2323

2424
This package includes a number of utilities to help reduce boilerplate and reuse common functionality across projects:
2525

26-
* **Repeated Tasks**: Easily trigger periodic tasks on server startup.
26+
* **Repeated Tasks**: Easily trigger periodic tasks on server startup using **repeat_every**.
2727
```
28-
from fastapi_utilities.repeat import repeat_every
28+
from fastapi_utilities import repeat_every
2929
3030
@router.on_event('startup')
3131
@repeat_every(seconds=3)
3232
async def print_hello():
3333
print("hello")
3434
```
3535

36+
* **Cron Jobs**: Easily trigger cron jobs on server startup using **repeat_at** by providing a cron expression.
37+
```
38+
from fastapi_utilities import repeat_at
39+
40+
@router.on_event("startup")
41+
@repeat_at(cron="*/2 * * * *") #every 2nd minute
42+
async def hey():
43+
print("hey")
44+
```
45+
3646
---
3747

3848
## Requirements

fastapi_utilities/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from .repeat.repeat_every import repeat_every
2+
from .repeat.repeat_at import repeat_at

fastapi_utilities/repeat/__init__.py

Whitespace-only changes.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import asyncio
2+
import logging
3+
4+
from datetime import datetime
5+
from functools import wraps
6+
from asyncio import ensure_future
7+
from starlette.concurrency import run_in_threadpool
8+
from croniter import croniter
9+
10+
11+
def get_delta(cron):
12+
"""
13+
This function returns the time delta between now and the next cron execution time.
14+
"""
15+
now = datetime.now()
16+
cron = croniter(cron, now)
17+
return (cron.get_next(datetime) - now).total_seconds()
18+
19+
20+
def repeat_at(
21+
*,
22+
cron: str,
23+
logger: logging.Logger = None,
24+
raise_exceptions: bool = False,
25+
max_repetitions: int = None,
26+
):
27+
"""
28+
This function returns a decorator that makes a function execute periodically as per the cron expression provided.
29+
30+
:: Params ::
31+
------------
32+
cron: str
33+
Cron-style string for periodic execution, eg. '0 0 * * *' every midnight
34+
logger: logging.Logger (default None)
35+
Logger object to log exceptions
36+
raise_exceptions: bool (default False)
37+
Whether to raise exceptions or log them
38+
max_repetitions: int (default None)
39+
Maximum number of times to repeat the function. If None, repeat indefinitely.
40+
41+
"""
42+
43+
def decorator(func):
44+
is_coroutine = asyncio.iscoroutinefunction(func)
45+
46+
@wraps(func)
47+
def wrapper(*args, **kwargs):
48+
repititions = 0
49+
if not croniter.is_valid(cron):
50+
raise ValueError("Invalid cron expression")
51+
52+
async def loop(*args, **kwargs):
53+
nonlocal repititions
54+
while max_repetitions is None or repititions < max_repetitions:
55+
try:
56+
sleepTime = get_delta(cron)
57+
await asyncio.sleep(sleepTime)
58+
if is_coroutine:
59+
await func(*args, **kwargs)
60+
else:
61+
await run_in_threadpool(func, *args, **kwargs)
62+
except Exception as e:
63+
if logger is not None:
64+
logger.exception(e)
65+
if raise_exceptions:
66+
raise e
67+
repititions += 1
68+
69+
ensure_future(loop(*args, **kwargs))
70+
71+
return wrapper
72+
73+
return decorator
File renamed without changes.

poetry.lock

Lines changed: 40 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ python = "^3.7"
3232
fastapi = "*"
3333
pydantic = "*"
3434
sqlalchemy = "*"
35+
croniter = "^1.0.13"
3536

3637
[tool.poetry.group.dev.dependencies]
3738
# Testing

requirements.txt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,9 @@ coverage[toml]==7.2.7 ; python_version >= "3.7" and python_version < "4.0" \
226226
--hash=sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e \
227227
--hash=sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850 \
228228
--hash=sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3
229+
croniter==1.4.1 ; python_version >= "3.7" and python_version < "4.0" \
230+
--hash=sha256:1a6df60eacec3b7a0aa52a8f2ef251ae3dd2a7c7c8b9874e73e791636d55a361 \
231+
--hash=sha256:9595da48af37ea06ec3a9f899738f1b2c1c13da3c38cea606ef7cd03ea421128
229232
exceptiongroup==1.1.3 ; python_version >= "3.7" and python_version < "3.11" \
230233
--hash=sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9 \
231234
--hash=sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3
@@ -430,9 +433,15 @@ pytest-cov==4.1.0 ; python_version >= "3.7" and python_version < "4.0" \
430433
pytest==7.4.2 ; python_version >= "3.7" and python_version < "4.0" \
431434
--hash=sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002 \
432435
--hash=sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069
436+
python-dateutil==2.8.2 ; python_version >= "3.7" and python_version < "4.0" \
437+
--hash=sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86 \
438+
--hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9
433439
requests==2.31.0 ; python_version >= "3.7" and python_version < "4.0" \
434440
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
435441
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1
442+
six==1.16.0 ; python_version >= "3.7" and python_version < "4.0" \
443+
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
444+
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
436445
sniffio==1.3.0 ; python_version >= "3.7" and python_version < "4.0" \
437446
--hash=sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101 \
438447
--hash=sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384

tests/test_repeat_at.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import logging
2+
import asyncio
3+
import pytest
4+
from _pytest.capture import CaptureFixture
5+
from _pytest.logging import LogCaptureFixture
6+
from fastapi_utilities import repeat_at
7+
8+
9+
@pytest.mark.asyncio
10+
async def test_repeat_at(capsys: CaptureFixture[str]):
11+
"""
12+
Simple Test Case for repeat_at
13+
"""
14+
15+
@repeat_at(cron="* * * * *", max_repetitions=3)
16+
async def print_hello():
17+
print("Hello")
18+
19+
print_hello()
20+
await asyncio.sleep(1)
21+
out, err = capsys.readouterr()
22+
assert err == ""
23+
assert out == ""
24+
25+
26+
@pytest.mark.asyncio
27+
async def test_repeat_at_def(capsys: CaptureFixture[str]):
28+
"""
29+
Simple Test Case for repeat_at
30+
"""
31+
32+
@repeat_at(cron="* * * * *", max_repetitions=3)
33+
def print_hello():
34+
print("Hello")
35+
36+
print_hello()
37+
await asyncio.sleep(1)
38+
out, err = capsys.readouterr()
39+
assert err == ""
40+
assert out == ""
41+
42+
43+
@pytest.mark.asyncio
44+
async def test_repeat_at_with_logger(caplog: LogCaptureFixture):
45+
"""
46+
Test Case for repeat_at with logger
47+
"""
48+
49+
@repeat_at(cron="* * * * *", logger=logging.getLogger("test"), max_repetitions=3)
50+
async def print_hello():
51+
raise Exception("Hello")
52+
53+
print_hello()
54+
await asyncio.sleep(60)
55+
56+
captured_logs = caplog.records
57+
58+
assert len(captured_logs) > 0
59+
60+
61+
from asyncio import AbstractEventLoop
62+
from typing import Any, Dict
63+
64+
65+
def ignore_exception(_loop: AbstractEventLoop, _context: Dict[str, Any]) -> None:
66+
pass
67+
68+
69+
@pytest.fixture(autouse=True)
70+
def setup_event_loop(event_loop: AbstractEventLoop) -> None:
71+
event_loop.set_exception_handler(ignore_exception)
72+
73+
74+
@pytest.mark.asyncio
75+
async def test_repeat_at_exception(capsys: CaptureFixture[str]) -> None:
76+
"""
77+
Test Case for repeat_at with an invalid cron expression
78+
"""
79+
80+
logger = logging.getLogger(__name__)
81+
82+
@repeat_at(
83+
cron="* * * * *", max_repetitions=None, raise_exceptions=True, logger=logger
84+
)
85+
def raise_exc():
86+
raise ValueError("repeat")
87+
88+
try:
89+
raise_exc()
90+
await asyncio.sleep(60)
91+
except ValueError:
92+
out, err = capsys.readouterr()
93+
assert out == ""
94+
assert err == ""
95+
96+
97+
@pytest.mark.asyncio
98+
async def test_repeat_at_invalid_cron(capsys: CaptureFixture[str]) -> None:
99+
"""
100+
Test Case for repeat_at with an invalid cron expression
101+
"""
102+
103+
logger = logging.getLogger(__name__)
104+
105+
@repeat_at(
106+
cron="invalid", max_repetitions=None, raise_exceptions=True, logger=logger
107+
)
108+
def raise_exc():
109+
raise ValueError("repeat")
110+
111+
try:
112+
await raise_exc()
113+
await asyncio.sleep(60)
114+
except ValueError:
115+
out, err = capsys.readouterr()
116+
assert out == ""
117+
assert err == ""

tests/test_repeat.py renamed to tests/test_repeat_every.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from _pytest.capture import CaptureFixture
66
from _pytest.logging import LogCaptureFixture
7-
from fastapi_utilities.repeat import repeat_every
7+
from fastapi_utilities import repeat_every
88

99

1010
@pytest.mark.asyncio

0 commit comments

Comments
 (0)