Skip to content

Commit f0ac543

Browse files
committed
fix: round-trip timedelta cron offset (#605)
pydantic serializes a timedelta offset to an ISO-8601 duration string (e.g. "PT4H"). Because the offset fields are typed as str | timedelta, re-validating kept the value as a str, so the scheduler later passed it to ZoneInfo("PT4H") and the schedule broke. Add a shared parse_cron_offset validator that restores a timedelta from its serialized duration form while leaving genuine timezone names (e.g. "US/Eastern") and other values untouched. Apply it as a before-validator on CronSpec.offset and ScheduledTask.cron_offset (pydantic v1 and v2). Closes #605
1 parent 9f8db96 commit f0ac543

5 files changed

Lines changed: 120 additions & 4 deletions

File tree

taskiq/scheduler/scheduled_task/cron_spec.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,22 @@
1+
from collections.abc import Callable
12
from datetime import timedelta
3+
from typing import Any
24

35
from pydantic import BaseModel
46

7+
from taskiq.compat import IS_PYDANTIC2
8+
from taskiq.scheduler.scheduled_task.validators import parse_cron_offset
9+
10+
_offset_before_validator: Callable[..., Any]
11+
if IS_PYDANTIC2:
12+
from pydantic import field_validator
13+
14+
_offset_before_validator = field_validator("offset", mode="before")
15+
else:
16+
from pydantic import validator
17+
18+
_offset_before_validator = validator("offset", pre=True)
19+
520

621
class CronSpec(BaseModel):
722
"""Cron specification for running tasks."""
@@ -14,6 +29,11 @@ class CronSpec(BaseModel):
1429

1530
offset: str | timedelta | None = None
1631

32+
@_offset_before_validator
33+
@classmethod
34+
def _parse_offset(cls, value: Any) -> Any:
35+
return parse_cron_offset(value)
36+
1737
def to_cron(self) -> str: # pragma: no cover
1838
"""Converts cron spec to cron string."""
1939
return f"{self.minutes} {self.hours} {self.days} {self.months} {self.weekdays}"

taskiq/scheduler/scheduled_task/v1.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@
22
from datetime import datetime, timedelta
33
from typing import Any
44

5-
from pydantic import BaseModel, Field, root_validator
5+
from pydantic import BaseModel, Field, root_validator, validator
66

7-
from taskiq.scheduler.scheduled_task.validators import validate_interval_value
7+
from taskiq.scheduler.scheduled_task.validators import (
8+
parse_cron_offset,
9+
validate_interval_value,
10+
)
811

912

1013
class ScheduledTask(BaseModel):
@@ -21,6 +24,11 @@ class ScheduledTask(BaseModel):
2124
time: datetime | None = None
2225
interval: int | timedelta | None = None
2326

27+
@validator("cron_offset", pre=True)
28+
@classmethod
29+
def _parse_cron_offset(cls, value: Any) -> Any:
30+
return parse_cron_offset(value)
31+
2432
@root_validator(pre=False) # type: ignore
2533
@classmethod
2634
def __check(cls, values: dict[str, Any]) -> dict[str, Any]:

taskiq/scheduler/scheduled_task/v2.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33
from datetime import datetime, timedelta
44
from typing import Any
55

6-
from pydantic import BaseModel, Field, model_validator
6+
from pydantic import BaseModel, Field, field_validator, model_validator
77

8-
from taskiq.scheduler.scheduled_task.validators import validate_interval_value
8+
from taskiq.scheduler.scheduled_task.validators import (
9+
parse_cron_offset,
10+
validate_interval_value,
11+
)
912

1013
if sys.version_info >= (3, 11):
1114
from typing import Self
@@ -27,6 +30,11 @@ class ScheduledTask(BaseModel):
2730
time: datetime | None = None
2831
interval: int | timedelta | None = None
2932

33+
@field_validator("cron_offset", mode="before")
34+
@classmethod
35+
def _parse_cron_offset(cls, value: Any) -> Any:
36+
return parse_cron_offset(value)
37+
3038
@model_validator(mode="after")
3139
def __check(self) -> Self:
3240
"""Validate.

taskiq/scheduler/scheduled_task/validators.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,31 @@
11
from datetime import timedelta
2+
from typing import Any
3+
4+
from pydantic import ValidationError
5+
6+
from taskiq.compat import parse_obj_as
7+
8+
9+
def parse_cron_offset(value: Any) -> Any:
10+
"""Restore a ``timedelta`` cron offset from its serialized form.
11+
12+
pydantic serializes a ``timedelta`` offset to an ISO-8601 duration
13+
string (e.g. ``"PT4H"``). Since the offset field is typed as
14+
``str | timedelta``, on reload the value stays a ``str``, which then
15+
breaks timezone handling in the scheduler (``ZoneInfo("PT4H")``).
16+
17+
Parse such duration strings back to ``timedelta`` while leaving genuine
18+
timezone names (e.g. ``"US/Eastern"``) and other values untouched.
19+
20+
:param value: raw offset value coming from validation.
21+
:return: a ``timedelta`` for serialized durations, otherwise ``value``.
22+
"""
23+
if not isinstance(value, str):
24+
return value
25+
try:
26+
return parse_obj_as(timedelta, value)
27+
except ValidationError:
28+
return value
229

330

431
def validate_interval_value(
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Regression tests for #605.
2+
3+
A ``timedelta`` offset is serialized by pydantic to an ISO-8601 duration
4+
string (e.g. ``"PT4H"``). On reload the ``str | timedelta`` union kept it as
5+
``str``, which then broke timezone handling in the scheduler
6+
(``ZoneInfo("PT4H")``). The offset must round-trip back to ``timedelta``
7+
while genuine timezone names stay untouched.
8+
"""
9+
10+
from datetime import timedelta
11+
12+
from taskiq.compat import model_dump, model_validate
13+
from taskiq.scheduler.scheduled_task import CronSpec, ScheduledTask
14+
15+
16+
def test_cron_spec_timedelta_offset_roundtrip() -> None:
17+
offset = timedelta(hours=4)
18+
restored = model_validate(CronSpec, model_dump(CronSpec(offset=offset)))
19+
assert restored.offset == offset
20+
assert isinstance(restored.offset, timedelta)
21+
22+
23+
def test_cron_spec_timezone_name_offset_preserved() -> None:
24+
restored = model_validate(CronSpec, model_dump(CronSpec(offset="US/Eastern")))
25+
assert restored.offset == "US/Eastern"
26+
27+
28+
def test_scheduled_task_timedelta_cron_offset_roundtrip() -> None:
29+
offset = timedelta(hours=2)
30+
task = ScheduledTask(
31+
task_name="a",
32+
labels={},
33+
args=[],
34+
kwargs={},
35+
cron="* * * * *",
36+
cron_offset=offset,
37+
)
38+
restored = model_validate(ScheduledTask, model_dump(task))
39+
assert restored.cron_offset == offset
40+
assert isinstance(restored.cron_offset, timedelta)
41+
42+
43+
def test_scheduled_task_timezone_name_cron_offset_preserved() -> None:
44+
task = ScheduledTask(
45+
task_name="a",
46+
labels={},
47+
args=[],
48+
kwargs={},
49+
cron="* * * * *",
50+
cron_offset="US/Eastern",
51+
)
52+
restored = model_validate(ScheduledTask, model_dump(task))
53+
assert restored.cron_offset == "US/Eastern"

0 commit comments

Comments
 (0)