Recurring schedules with dateutil rrule and zoneinfo DST handling, frozen with time-machine: working versions (tested 2026-10-15)
Build daily wall-clock schedules in a named timezone with dateutil rrule and zoneinfo, then test DST gaps, overlaps and naive datetimes offline with time-machine.
passed4 passed · 0 failed2026-10-15 13:28 UTC- Status
- passed
- Last passed
- 2026-10-15 13:28 UTC
- Tests
- 4 / 4
- Language
- Python
- Runtime
- Python 3.13.16
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("dateutil-rrule-zoneinfo-time-machine") (install), or see them on GitHub.
python-dateutil==2.9.0.post0 six==1.17.0 time-machine==3.5.1 pytest==9.1.1
from datetime import datetime, timezone
from zoneinfo import ZoneInfo
from dateutil.rrule import DAILY, rrule
NY = ZoneInfo("America/New_York")
def daily_at(start: datetime, hour: int, minute: int) -> rrule:
"""Every day at hour:minute wall-clock time. start must be timezone-aware."""
return rrule(DAILY, dtstart=start, byhour=hour, byminute=minute, bysecond=0)
def next_run(rule: rrule, now: datetime) -> datetime:
return rule.after(now, inc=False)
def to_utc(dt: datetime) -> datetime:
return dt.astimezone(timezone.utc)
from datetime import datetime, timedelta, timezone
import pytest
import time_machine
from schedule import NY, daily_at, next_run, to_utc
START = datetime(2024, 3, 1, tzinfo=NY)
@time_machine.travel(datetime(2024, 3, 9, 17, tzinfo=timezone.utc), tick=False)
def test_wall_clock_preserved_across_spring_forward():
# 09:00 stays 09:00 local; only the UTC offset changes (EST -5 to EDT -4)
nxt = next_run(daily_at(START, 9, 0), datetime.now(NY))
assert nxt.hour == 9
assert nxt.utcoffset() == timedelta(hours=-4)
def test_nonexistent_local_time_uses_pre_gap_offset():
# 02:30 does not exist on 2024-03-10; zoneinfo (fold=0) uses the offset before the gap,
# so 02:30 EST = 07:30 UTC
nxt = next_run(daily_at(START, 2, 30), datetime(2024, 3, 9, 12, tzinfo=NY))
assert nxt.hour == 2
assert to_utc(nxt) == datetime(2024, 3, 10, 7, 30, tzinfo=timezone.utc)
def test_ambiguous_time_fires_once_per_day():
# 01:30 happens twice on 2024-11-03; rrule yields one occurrence per day, fold=0 (EDT)
runs = daily_at(START, 1, 30).between(datetime(2024, 11, 2, tzinfo=NY), datetime(2024, 11, 4, tzinfo=NY))
assert len(runs) == 2
assert runs[1].utcoffset() == timedelta(hours=-4)
def test_naive_dtstart_is_rejected():
rule = daily_at(datetime(2024, 3, 1), 9, 0)
with pytest.raises(TypeError):
next_run(rule, datetime(2024, 3, 9, tzinfo=NY))
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
Click CLI for RRULE next run times, tested with freezegun and CliRunner
A Click command that computes the next run times of an RFC 5545 rule in any IANA zone with dateutil rrule and zoneinfo, with DST gaps normalized and time frozen with freezegun in tests.
APScheduler job triggers tested deterministically with time-machine
Compute CronTrigger and IntervalTrigger fire times without a running scheduler, and test them offline by freezing the clock with time-machine.
All python-dateutil recipesAll zoneinfo recipesAll time-machine recipes