Skip to content

Commit 0b661ea

Browse files
committed
feat: add cron validation
1 parent f08a54a commit 0b661ea

10 files changed

Lines changed: 328 additions & 22 deletions

File tree

‎docs/available-components/schedule-sources.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ The format of the schedule label is the following:
3636
@broker.task(
3737
schedule=[
3838
{
39-
"cron": "* * * * *", # type: str | None, either cron, interval or time should be specified.
39+
"cron": "* * * * *", # type: str | Cron | None, either cron, interval or time should be specified.
4040
"cron_offset": None, # type: str | timedelta | None, can be omitted.
4141
"interval": None, # type: int | timedelta | None, either cron, interval or time should be specified.
4242
"time": None, # type: datetime | None, either cron, interval or time should be specified.
@@ -53,7 +53,7 @@ async def my_task():
5353

5454
Parameters:
5555

56-
- `cron` - crontab string when to run the task.
56+
- `cron` - crontab string (5 fields; names like `MON`/`JAN` are allowed) when to run the task. Invalid values are rejected when the schedule is created.
5757
- `cron_offset` - timezone offset for cron values. Explained [here](../guide/scheduling-tasks.md#working-with-timezones)
5858
- `interval` - Interval to run periodic tasks. Must be at least 1 second and cannot have fractional seconds.
5959
- `time` - specific time when to send the task.

‎taskiq/__init__.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
SmartRetryMiddleware,
3333
)
3434
from taskiq.result import TaskiqResult
35-
from taskiq.scheduler.scheduled_task import ScheduledTask
35+
from taskiq.scheduler.scheduled_task import Cron, ScheduledTask
3636
from taskiq.scheduler.scheduler import TaskiqScheduler
3737
from taskiq.state import TaskiqState
3838
from taskiq.task import AsyncTaskiqTask
@@ -48,6 +48,7 @@
4848
"AsyncTaskiqTask",
4949
"BrokerMessage",
5050
"Context",
51+
"Cron",
5152
"InMemoryBroker",
5253
"NoResultError",
5354
"PrometheusMiddleware",

‎taskiq/cli/scheduler/run.py‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from taskiq.abc.schedule_source import ScheduleSource
1212
from taskiq.cli.scheduler.args import SchedulerArgs
1313
from taskiq.cli.utils import import_object, import_tasks
14-
from taskiq.scheduler.scheduled_task import ScheduledTask
14+
from taskiq.scheduler.scheduled_task import Cron, CronError, ScheduledTask
1515
from taskiq.scheduler.scheduler import TaskiqScheduler
1616

1717
logger = getLogger(__name__)
@@ -77,7 +77,7 @@ class CronValueError(Exception):
7777

7878

7979
def is_cron_task_now(
80-
cron_value: str,
80+
cron_value: str | Cron,
8181
now: datetime,
8282
offset: str | timedelta | None = None,
8383
last_run: datetime | None = None,
@@ -108,8 +108,10 @@ def is_cron_task_now(
108108
raise CronValueError(e) from e
109109

110110
try:
111-
return pycron.is_now(cron_value, now)
112-
except ValueError as e:
111+
if isinstance(cron_value, str):
112+
cron_value = Cron.parse(cron_value)
113+
return pycron.is_now(cron_value.to_pycron(), now)
114+
except (CronError, ValueError) as e:
113115
raise CronValueError(e) from e
114116

115117

‎taskiq/decor.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
if TYPE_CHECKING: # pragma: no cover
2121
from taskiq.abc.broker import AsyncBroker
2222
from taskiq.abc.schedule_source import ScheduleSource
23-
from taskiq.scheduler.scheduled_task import CronSpec
23+
from taskiq.scheduler.scheduled_task import Cron, CronSpec
2424

2525
_T = TypeVar("_T")
2626
_FuncParams = ParamSpec("_FuncParams")
@@ -139,7 +139,7 @@ async def kiq(
139139
async def schedule_by_cron(
140140
self,
141141
source: "ScheduleSource",
142-
cron: Union[str, "CronSpec"],
142+
cron: Union[str, "CronSpec", "Cron"],
143143
*args: _FuncParams.args,
144144
**kwargs: _FuncParams.kwargs,
145145
) -> CreatedSchedule[_ReturnType]:
@@ -150,7 +150,7 @@ async def schedule_by_cron(
150150
which is capable of dynamically adding new schedules.
151151
152152
:param source: schedule source.
153-
:param cron: cron string or a CronSpec instance.
153+
:param cron: cron string, Cron or CronSpec instance.
154154
:param args: function's arguments.
155155
:param kwargs: function's key word arguments.
156156
:return: schedule id.

‎taskiq/kicker.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from taskiq.labels import prepare_label
2121
from taskiq.message import TaskiqMessage
2222
from taskiq.scheduler.created_schedule import CreatedSchedule
23-
from taskiq.scheduler.scheduled_task import CronSpec, ScheduledTask
23+
from taskiq.scheduler.scheduled_task import Cron, CronSpec, ScheduledTask
2424
from taskiq.task import AsyncTaskiqTask
2525
from taskiq.utils import maybe_awaitable
2626

@@ -180,15 +180,15 @@ async def kiq(
180180
async def schedule_by_cron(
181181
self,
182182
source: "ScheduleSource",
183-
cron: Union[str, "CronSpec"],
183+
cron: Union[str, "CronSpec", "Cron"],
184184
*args: _FuncParams.args,
185185
**kwargs: _FuncParams.kwargs,
186186
) -> CreatedSchedule[_ReturnType]:
187187
"""
188188
Function to schedule task with cron.
189189
190190
:param source: schedule source.
191-
:param cron: cron expression.
191+
:param cron: cron string, Cron or CronSpec instance.
192192
:param args: function's args.
193193
:param kwargs: function's kwargs.
194194
@@ -200,18 +200,18 @@ async def schedule_by_cron(
200200
message = self._prepare_message(*args, **kwargs)
201201
cron_offset = None
202202
if isinstance(cron, CronSpec):
203-
cron_str = cron.to_cron()
203+
cron_value = cron.to_cron()
204204
cron_offset = cron.offset
205205
else:
206-
cron_str = cron
206+
cron_value = Cron.parse(cron) if isinstance(cron, str) else cron
207207
scheduled = ScheduledTask(
208208
schedule_id=schedule_id,
209209
task_name=message.task_name,
210210
labels=message.labels,
211211
args=message.args,
212212
kwargs=message.kwargs,
213213
task_id=self.custom_task_id,
214-
cron=cron_str,
214+
cron=cron_value,
215215
cron_offset=cron_offset,
216216
)
217217
await source.add_schedule(scheduled)
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1+
from .cron import Cron, CronError
12
from .cron_spec import CronSpec
23
from .task import ScheduledTask
34

4-
__all__ = ["CronSpec", "ScheduledTask"]
5+
__all__ = ["Cron", "CronError", "CronSpec", "ScheduledTask"]
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
from collections.abc import Mapping
2+
from dataclasses import dataclass, field
3+
from enum import Enum
4+
from typing import Annotated, Any
5+
6+
from pydantic import PlainSerializer, PlainValidator, WithJsonSchema
7+
8+
9+
class CronError(ValueError):
10+
"""Raised on invalid cron expression."""
11+
12+
13+
class WeekDays(str, Enum):
14+
"""Weekday names."""
15+
16+
SUN = "SUN"
17+
MON = "MON"
18+
TUE = "TUE"
19+
WED = "WED"
20+
THU = "THU"
21+
FRI = "FRI"
22+
SAT = "SAT"
23+
24+
25+
class Months(str, Enum):
26+
"""Month names."""
27+
28+
JAN = "JAN"
29+
FEB = "FEB"
30+
MAR = "MAR"
31+
APR = "APR"
32+
MAY = "MAY"
33+
JUN = "JUN"
34+
JUL = "JUL"
35+
AUG = "AUG"
36+
SEP = "SEP"
37+
OCT = "OCT"
38+
NOV = "NOV"
39+
DEC = "DEC"
40+
41+
42+
@dataclass(frozen=True)
43+
class FieldSpec:
44+
"""Allowed values of a cron field."""
45+
46+
name: str
47+
low: int
48+
high: int
49+
aliases: Mapping[str, int] = field(default_factory=dict)
50+
51+
52+
MONTH_NAMES: dict[str, int] = {n.value: i for i, n in enumerate(Months, start=1)}
53+
WEEKDAY_NAMES: dict[str, int] = {n.value: i for i, n in enumerate(WeekDays)}
54+
55+
MINUTE = FieldSpec("minute", 0, 59)
56+
HOUR = FieldSpec("hour", 0, 23)
57+
DAY = FieldSpec("day", 1, 31)
58+
MONTH = FieldSpec("month", 1, 12, MONTH_NAMES)
59+
WEEKDAY = FieldSpec("weekday", 0, 7, WEEKDAY_NAMES) # 0 and 7 = SUN
60+
61+
62+
def _is_uint(s: str) -> bool:
63+
return s.isascii() and s.isdigit()
64+
65+
66+
@dataclass(frozen=True)
67+
class CronField:
68+
"""
69+
Single cron field.
70+
71+
Accepted values:
72+
*: any value
73+
N: one value
74+
A-B: a range, A <= B
75+
*/S: a step within the whole range
76+
A-B/S: a step inside the range
77+
Comma separated lists of the above.
78+
"""
79+
80+
value: str
81+
spec: FieldSpec
82+
83+
def __post_init__(self) -> None:
84+
for part in self.value.split(","):
85+
self._check_part(part)
86+
87+
def _check_part(self, part: str) -> None:
88+
name = self.spec.name
89+
90+
base, has_step, step = part.partition("/")
91+
if has_step:
92+
if not _is_uint(step) or int(step) == 0:
93+
raise CronError(f"{name}: the step should be > 0, got {step!r}")
94+
if base != "*" and "-" not in base:
95+
raise CronError(
96+
f"{name}: the step is allowed only after '*' or a range, "
97+
f"got {part!r}",
98+
)
99+
100+
if base == "*":
101+
return
102+
103+
start, has_range, end = base.partition("-")
104+
lo = self._to_number(start)
105+
if has_range:
106+
hi = self._to_number(end)
107+
if lo > hi:
108+
raise CronError(f"{name}: start must be lower than the end {part!r}")
109+
110+
def _to_number(self, token: str) -> int:
111+
spec = self.spec
112+
alias = token.upper()
113+
if alias in spec.aliases:
114+
return spec.aliases[alias]
115+
if not _is_uint(token):
116+
raise CronError(f"{spec.name}: a number expected, got {token!r}")
117+
n = int(token)
118+
if not spec.low <= n <= spec.high:
119+
raise CronError(f"{spec.name}: {n} is out of range {spec.low}-{spec.high}")
120+
return n
121+
122+
def to_numeric(self) -> str:
123+
"""Convert the field to numbers only, in the form pycron understands."""
124+
is_weekday = self.spec is WEEKDAY
125+
parts: list[str] = []
126+
for part in self.value.split(","):
127+
base, _, step = part.partition("/")
128+
if base == "*":
129+
parts.append(part)
130+
continue
131+
start, has_range, end = base.partition("-")
132+
lo = self._to_number(start)
133+
if not has_range:
134+
parts.append(str(lo % 7 if is_weekday else lo))
135+
elif is_weekday:
136+
values = range(lo, self._to_number(end) + 1, int(step or 1))
137+
parts.extend(str(v % 7) for v in values)
138+
else:
139+
suffix = f"/{step}" if step else ""
140+
parts.append(f"{lo}-{self._to_number(end)}{suffix}")
141+
return ",".join(parts)
142+
143+
144+
@dataclass
145+
class Cron:
146+
"""Validated 5-field cron expression."""
147+
148+
minute: CronField
149+
hour: CronField
150+
day: CronField
151+
month: CronField
152+
weekday: CronField
153+
154+
def __post_init__(self) -> None:
155+
expected = (MINUTE, HOUR, DAY, MONTH, WEEKDAY)
156+
actual = (self.minute, self.hour, self.day, self.month, self.weekday)
157+
for f, spec in zip(actual, expected, strict=True):
158+
if f.spec is not spec:
159+
raise CronError(
160+
f"field {spec.name} is created with wrong spec: {f.spec.name}",
161+
)
162+
163+
@classmethod
164+
def parse(cls, expr: str) -> "Cron":
165+
"""
166+
Parse cron string.
167+
168+
:param expr: cron expression with 5 fields.
169+
:raises CronError: if the expression is invalid.
170+
:return: cron object.
171+
"""
172+
parts = expr.split()
173+
if len(parts) != 5:
174+
raise CronError(f"Need 5 fields, got: {len(parts)}: {expr!r}")
175+
176+
specs = (MINUTE, HOUR, DAY, MONTH, WEEKDAY)
177+
return cls(*(CronField(v, s) for v, s in zip(parts, specs, strict=True)))
178+
179+
def to_pycron(self) -> str:
180+
"""Cron string with numeric values only, suitable for pycron."""
181+
fields = (self.minute, self.hour, self.day, self.month, self.weekday)
182+
return " ".join(f.to_numeric() for f in fields)
183+
184+
def __str__(self) -> str:
185+
return (
186+
f"{self.minute.value} {self.hour.value} {self.day.value} "
187+
f"{self.month.value} {self.weekday.value}"
188+
)
189+
190+
191+
def _validate_cron(value: Any) -> Cron:
192+
if isinstance(value, Cron):
193+
return value
194+
if isinstance(value, str):
195+
return Cron.parse(value)
196+
raise CronError(f"cron must be a string or Cron, got {type(value).__name__}")
197+
198+
199+
CronExpression = Annotated[
200+
Cron | str,
201+
PlainValidator(_validate_cron),
202+
PlainSerializer(str, return_type=str),
203+
WithJsonSchema({"type": "string"}),
204+
]

‎taskiq/scheduler/scheduled_task/cron_spec.py‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
from pydantic import BaseModel
1+
from pydantic import BaseModel, model_validator
22

3+
from taskiq.scheduler.scheduled_task.cron import Cron
34
from taskiq.scheduler.scheduled_task.validators import CronOffset
45

56

@@ -14,6 +15,13 @@ class CronSpec(BaseModel):
1415

1516
offset: CronOffset | None = None
1617

17-
def to_cron(self) -> str: # pragma: no cover
18-
"""Converts cron spec to cron string."""
19-
return f"{self.minutes} {self.hours} {self.days} {self.months} {self.weekdays}"
18+
@model_validator(mode="after")
19+
def _validate_cron(self) -> "CronSpec":
20+
self.to_cron()
21+
return self
22+
23+
def to_cron(self) -> Cron:
24+
"""Converts cron spec to a validated cron object."""
25+
return Cron.parse(
26+
f"{self.minutes} {self.hours} {self.days} {self.months} {self.weekdays}",
27+
)

‎taskiq/scheduler/scheduled_task/task.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from pydantic import BaseModel, Field, model_validator
77

8+
from taskiq.scheduler.scheduled_task.cron import CronExpression
89
from taskiq.scheduler.scheduled_task.validators import (
910
CronOffset,
1011
validate_interval_value,
@@ -25,7 +26,7 @@ class ScheduledTask(BaseModel):
2526
kwargs: dict[str, Any]
2627
task_id: str | None = None
2728
schedule_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
28-
cron: str | None = None
29+
cron: CronExpression | None = None
2930
cron_offset: CronOffset | None = None
3031
time: datetime | None = None
3132
interval: int | timedelta | None = None

0 commit comments

Comments
 (0)