Skip to content

Commit 7727757

Browse files
author
Sergio García Prado
authored
Merge pull request #318 from minos-framework/issue-306-add-reboot-crontab
#306 - Add `@reboot` support to `CronTab`
2 parents 5dcc4a0 + 88a4f2a commit 7727757

File tree

7 files changed

+251
-23
lines changed

7 files changed

+251
-23
lines changed

packages/core/minos-microservice-networks/minos/networks/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
Router,
119119
)
120120
from .scheduling import (
121+
CronTab,
121122
PeriodicTask,
122123
PeriodicTaskScheduler,
123124
PeriodicTaskSchedulerPort,

packages/core/minos-microservice-networks/minos/networks/decorators/definitions/periodic.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,48 @@
1+
from __future__ import (
2+
annotations,
3+
)
4+
15
from abc import (
26
ABC,
37
)
48
from collections.abc import (
59
Iterable,
610
)
711
from typing import (
12+
TYPE_CHECKING,
813
Final,
914
Union,
1015
)
1116

12-
from crontab import (
13-
CronTab,
14-
)
15-
1617
from .abc import (
1718
EnrouteDecorator,
1819
)
1920
from .kinds import (
2021
EnrouteDecoratorKind,
2122
)
2223

24+
if TYPE_CHECKING:
25+
from crontab import CronTab as CronTabImpl
26+
27+
from ...scheduling import (
28+
CronTab,
29+
)
30+
2331

2432
class PeriodicEnrouteDecorator(EnrouteDecorator, ABC):
2533
"""Periodic Enroute class"""
2634

27-
def __init__(self, crontab: Union[str, CronTab]):
28-
if isinstance(crontab, str):
35+
def __init__(self, crontab: Union[str, CronTab, CronTabImpl]):
36+
from ...scheduling import (
37+
CronTab,
38+
)
39+
40+
if not isinstance(crontab, CronTab):
2941
crontab = CronTab(crontab)
3042
self.crontab = crontab
3143

3244
def __iter__(self) -> Iterable:
33-
yield from (self.crontab.matchers,)
45+
yield from (self.crontab,)
3446

3547

3648
class PeriodicEventEnrouteDecorator(PeriodicEnrouteDecorator):

packages/core/minos-microservice-networks/minos/networks/scheduling/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from .crontab import (
2+
CronTab,
3+
)
14
from .ports import (
25
PeriodicTaskSchedulerPort,
36
)
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
from __future__ import (
2+
annotations,
3+
)
4+
5+
import asyncio
6+
from datetime import (
7+
datetime,
8+
)
9+
from itertools import (
10+
count,
11+
)
12+
from math import (
13+
inf,
14+
)
15+
from typing import (
16+
Any,
17+
AsyncIterator,
18+
Optional,
19+
Union,
20+
)
21+
22+
from crontab import CronTab as CrontTabImpl
23+
24+
from minos.common import (
25+
current_datetime,
26+
)
27+
28+
29+
class CronTab:
30+
"""CronTab class."""
31+
32+
def __init__(self, pattern: Union[str, CrontTabImpl]):
33+
if isinstance(pattern, str) and pattern == "@reboot":
34+
pattern = None
35+
elif not isinstance(pattern, CrontTabImpl):
36+
pattern = CrontTabImpl(pattern)
37+
self._impl = pattern
38+
39+
@property
40+
def impl(self) -> Optional[CrontTabImpl]:
41+
"""Get the crontab implementation.
42+
43+
:return: A ``crontab.CronTab`` or ``None``.
44+
"""
45+
return self._impl
46+
47+
def __hash__(self):
48+
return hash((type(self), self._impl_matchers))
49+
50+
def __eq__(self, other: Any) -> bool:
51+
return isinstance(other, type(self)) and self._impl_matchers == other._impl_matchers
52+
53+
@property
54+
def _impl_matchers(self):
55+
if self._impl is None:
56+
return None
57+
return self._impl.matchers
58+
59+
async def __aiter__(self) -> AsyncIterator[datetime]:
60+
counter = count()
61+
now = current_datetime()
62+
while next(counter) < self.repetitions:
63+
await self.sleep_until_next(now)
64+
now = current_datetime()
65+
yield now
66+
67+
await asyncio.sleep(inf)
68+
69+
async def sleep_until_next(self, *args, **kwargs) -> None:
70+
"""Sleep until next matching.
71+
72+
:param args: Additional positional arguments.
73+
:param kwargs: Additional named arguments.
74+
:return: This method does not return anything.
75+
"""
76+
await asyncio.sleep(self.get_delay_until_next(*args, **kwargs))
77+
78+
def get_delay_until_next(self, now: Optional[datetime] = None) -> float:
79+
"""Get the time to wait for next matching.
80+
81+
:param now: Current time.
82+
:return:
83+
"""
84+
if self._impl is None:
85+
return 0
86+
87+
if now is None:
88+
now = current_datetime()
89+
return self._impl.next(now)
90+
91+
@property
92+
def repetitions(self) -> Union[int, float]:
93+
"""Get the number of repetitions.
94+
95+
:return: A ``float`` value.
96+
"""
97+
if self._impl is None:
98+
return 1
99+
return inf

packages/core/minos-microservice-networks/minos/networks/scheduling/schedulers.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@
2121
Union,
2222
)
2323

24-
from crontab import (
25-
CronTab,
26-
)
24+
from crontab import CronTab as CronTabImpl
2725

2826
from minos.common import (
2927
Config,
@@ -37,6 +35,9 @@
3735
from ..requests import (
3836
ResponseException,
3937
)
38+
from .crontab import (
39+
CronTab,
40+
)
4041
from .requests import (
4142
ScheduledRequest,
4243
)
@@ -93,8 +94,8 @@ class PeriodicTask:
9394

9495
_task: Optional[asyncio.Task]
9596

96-
def __init__(self, crontab: Union[str, CronTab], fn: Callable[[ScheduledRequest], Awaitable[None]]):
97-
if isinstance(crontab, str):
97+
def __init__(self, crontab: Union[str, CronTab, CronTabImpl], fn: Callable[[ScheduledRequest], Awaitable[None]]):
98+
if not isinstance(crontab, CronTab):
9899
crontab = CronTab(crontab)
99100

100101
self._crontab = crontab
@@ -161,12 +162,9 @@ async def run_forever(self) -> NoReturn:
161162
162163
:return: This method never returns.
163164
"""
164-
now = current_datetime()
165-
await asyncio.sleep(self._crontab.next(now))
166165

167-
while True:
168-
now = current_datetime()
169-
await asyncio.gather(asyncio.sleep(self._crontab.next(now)), self.run_once(now))
166+
async for now in self._crontab:
167+
await self.run_once(now)
170168

171169
@property
172170
def running(self) -> bool:
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import unittest
2+
from datetime import (
3+
datetime,
4+
time,
5+
timedelta,
6+
timezone,
7+
)
8+
from math import (
9+
inf,
10+
)
11+
from unittest.mock import (
12+
MagicMock,
13+
call,
14+
patch,
15+
)
16+
17+
from crontab import CronTab as CrontabImpl
18+
19+
from minos.common import (
20+
current_datetime,
21+
)
22+
from minos.networks import (
23+
CronTab,
24+
)
25+
26+
27+
class TestCronTab(unittest.IsolatedAsyncioTestCase):
28+
def test_constructor(self):
29+
crontab = CronTab("@daily")
30+
self.assertEqual(CrontabImpl("@daily").matchers, crontab.impl.matchers)
31+
32+
def test_constructor_reboot(self):
33+
crontab = CronTab("@reboot")
34+
self.assertEqual(None, crontab.impl)
35+
36+
def test_constructor_raises(self):
37+
with self.assertRaises(ValueError):
38+
CronTab("foo")
39+
40+
def test_repetitions(self):
41+
crontab = CronTab("@daily")
42+
self.assertEqual(inf, crontab.repetitions)
43+
44+
def test_repetitions_reboot(self):
45+
crontab = CronTab("@reboot")
46+
self.assertEqual(1, crontab.repetitions)
47+
48+
def test_get_delay_until_next(self):
49+
crontab = CronTab("@daily")
50+
now = current_datetime()
51+
52+
expected = (
53+
datetime.combine(now.date() + timedelta(days=1), time.min, tzinfo=timezone.utc) - now
54+
).total_seconds()
55+
self.assertAlmostEqual(expected, crontab.get_delay_until_next(), places=1)
56+
57+
def test_get_delay_until_next_reboot(self):
58+
crontab = CronTab("@reboot")
59+
self.assertEqual(0, crontab.get_delay_until_next())
60+
61+
def test_hash(self):
62+
crontab = CronTab("@daily")
63+
self.assertIsInstance(hash(crontab), int)
64+
65+
def test_hash_reboot(self):
66+
crontab = CronTab("@reboot")
67+
self.assertIsInstance(hash(crontab), int)
68+
69+
def test_eq(self):
70+
base = CronTab("@daily")
71+
one = CronTab("@daily")
72+
self.assertEqual(base, one)
73+
74+
two = CronTab("@hourly")
75+
self.assertNotEqual(base, two)
76+
77+
three = CronTab("@reboot")
78+
self.assertNotEqual(base, three)
79+
80+
async def test_sleep_until_next(self):
81+
crontab = CronTab("@reboot")
82+
83+
mock = MagicMock(return_value=1234)
84+
crontab.get_delay_until_next = mock
85+
86+
with patch("asyncio.sleep") as mock_sleep:
87+
await crontab.sleep_until_next()
88+
89+
self.assertEqual([call(1234)], mock_sleep.call_args_list)
90+
91+
async def test_aiter(self):
92+
crontab = CronTab("@reboot")
93+
94+
with patch("asyncio.sleep") as mock_sleep:
95+
count = 0
96+
async for now in crontab:
97+
count += 1
98+
self.assertAlmostEqual(current_datetime(), now, delta=timedelta(seconds=1))
99+
self.assertEqual(1, count)
100+
101+
self.assertEqual([call(0), call(inf)], mock_sleep.call_args_list)
102+
103+
104+
if __name__ == "__main__":
105+
unittest.main()

packages/core/minos-microservice-networks/tests/test_networks/test_scheduling/test_schedulers.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
import math
23
import unittest
34
import warnings
45
from unittest.mock import (
@@ -8,15 +9,12 @@
89
patch,
910
)
1011

11-
from crontab import (
12-
CronTab,
13-
)
14-
1512
from minos.common import (
1613
Config,
1714
current_datetime,
1815
)
1916
from minos.networks import (
17+
CronTab,
2018
PeriodicTask,
2119
PeriodicTaskScheduler,
2220
ScheduledRequest,
@@ -81,7 +79,7 @@ def setUp(self) -> None:
8179
self.periodic = PeriodicTask("@daily", self.fn_mock)
8280

8381
def test_crontab(self) -> None:
84-
self.assertEqual(CronTab("@daily").matchers, self.periodic.crontab.matchers)
82+
self.assertEqual(CronTab("@daily"), self.periodic.crontab)
8583

8684
def test_fn(self) -> None:
8785
self.assertEqual(self.fn_mock, self.periodic.fn)
@@ -110,13 +108,25 @@ async def test_stop(self) -> None:
110108

111109
async def test_run_forever(self) -> None:
112110
with patch("asyncio.sleep") as mock_sleep:
113-
run_once_mock = AsyncMock(side_effect=ValueError)
111+
run_once_mock = AsyncMock(side_effect=[int, ValueError])
114112
self.periodic.run_once = run_once_mock
115113

116114
with self.assertRaises(ValueError):
117115
await self.periodic.run_forever()
118116

119117
self.assertEqual(2, mock_sleep.call_count)
118+
self.assertEqual(2, run_once_mock.call_count)
119+
120+
async def test_run_forever_once(self) -> None:
121+
periodic = PeriodicTask("@reboot", self.fn_mock)
122+
with patch("asyncio.sleep", AsyncMock(side_effect=[int, ValueError])) as mock_sleep:
123+
run_once_mock = AsyncMock()
124+
periodic.run_once = run_once_mock
125+
126+
with self.assertRaises(ValueError):
127+
await periodic.run_forever()
128+
129+
self.assertEqual([call(0), call(math.inf)], mock_sleep.call_args_list)
120130
self.assertEqual(1, run_once_mock.call_count)
121131

122132
async def test_run_once(self) -> None:

0 commit comments

Comments
 (0)