Skip to content

Commit b85c7d4

Browse files
omikaderOmar Abdelkader
andauthored
feat(cron): add support for custom timezones (#312)
The `croniter` library does support timezone-aware `datetimes`. Since `_croniter` is initialized with `datetime.now(timezone.utc) `as the base time, all cron expressions are interpreted in UTC. If you specify `"0 9 * * *"`, that's 9:00 AM UTC, not your system timezone. By providing an optional timezone argument, we give the user the ability to customize which timezone should be used so they don't need to manually change the UTC time twice a year for daylight saving time. --------- Co-authored-by: Omar Abdelkader <oabdelkader@nvidia.com>
1 parent f9c7d16 commit b85c7d4

File tree

2 files changed

+40
-3
lines changed

2 files changed

+40
-3
lines changed

src/docket/dependencies/_cron.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from datetime import datetime, timezone
5+
from datetime import datetime, timezone, tzinfo
66
from typing import TYPE_CHECKING
77

88
from croniter import croniter
@@ -29,6 +29,8 @@ class Cron(Perpetual):
2929
Example:
3030
3131
```python
32+
from zoneinfo import ZoneInfo
33+
3234
@task
3335
async def weekly_report(cron: Cron = Cron("0 9 * * 1")) -> None:
3436
# Runs every Monday at 9:00 AM UTC
@@ -38,17 +40,26 @@ async def weekly_report(cron: Cron = Cron("0 9 * * 1")) -> None:
3840
async def daily_cleanup(cron: Cron = Cron("@daily")) -> None:
3941
# Runs every day at midnight UTC
4042
...
43+
44+
@task
45+
async def morning_standup(
46+
cron: Cron = Cron("0 9 * * 1-5", tz=ZoneInfo("America/Los_Angeles"))
47+
) -> None:
48+
# Runs weekdays at 9:00 AM Pacific (handles DST automatically)
49+
...
4150
```
4251
"""
4352

4453
expression: str
54+
tz: tzinfo
4555

4656
_croniter: croniter[datetime]
4757

4858
def __init__(
4959
self,
5060
expression: str,
5161
automatic: bool = True,
62+
tz: tzinfo = timezone.utc,
5263
) -> None:
5364
"""
5465
Args:
@@ -61,14 +72,18 @@ def __init__(
6172
startup and continually through the worker's lifespan. This ensures
6273
that the task will always be scheduled despite crashes and other
6374
adverse conditions. Automatic tasks must not require any arguments.
75+
tz: Timezone for interpreting the cron expression. Defaults to UTC.
76+
Use `ZoneInfo("America/Los_Angeles")` for Pacific time, etc.
77+
This correctly handles daylight saving time transitions.
6478
"""
6579
super().__init__(automatic=automatic)
6680
self.expression = expression
67-
self._croniter = croniter(expression, datetime.now(timezone.utc), datetime)
81+
self.tz = tz
82+
self._croniter = croniter(self.expression, datetime.now(self.tz), datetime)
6883

6984
async def __aenter__(self) -> Cron:
7085
execution = self.execution.get()
71-
cron = Cron(expression=self.expression, automatic=self.automatic)
86+
cron = Cron(expression=self.expression, automatic=self.automatic, tz=self.tz)
7287
cron.args = execution.args
7388
cron.kwargs = execution.kwargs
7489
return cron

tests/fundamentals/test_cron.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from datetime import datetime, timedelta, timezone
44
from unittest.mock import patch
5+
from zoneinfo import ZoneInfo
56

67
from croniter import croniter
78

@@ -134,3 +135,24 @@ async def scheduled_task(cron: Cron = Cron("0 9 * * 1")): # Mondays at 9 AM
134135
assert len(calls) == 1
135136
# The task ran at or after the scheduled time, not immediately
136137
assert calls[0] >= future_time - timedelta(milliseconds=50)
138+
139+
140+
async def test_cron_with_timezone(docket: Docket, worker: Worker):
141+
"""Cron tasks can be scheduled in a specific timezone."""
142+
runs = 0
143+
144+
pacific = ZoneInfo("America/Los_Angeles")
145+
146+
async def pacific_task(cron: Cron = Cron("0 9 * * *", tz=pacific, automatic=False)):
147+
nonlocal runs
148+
runs += 1
149+
150+
with patch.object(
151+
croniter,
152+
"get_next",
153+
return_value=datetime.now(pacific) + timedelta(milliseconds=10),
154+
):
155+
execution = await docket.add(pacific_task)()
156+
await worker.run_at_most({execution.key: 2})
157+
158+
assert runs == 2

0 commit comments

Comments
 (0)