Click CLI for RRULE next run times, tested with freezegun and CliRunner: working versions (tested 2026-10-15)
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.
passed6 passed · 0 failed2026-10-15 13:27 UTC- Status
- passed
- Last passed
- 2026-10-15 13:27 UTC
- Tests
- 6 / 6
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions4
click@8.5.0python-dateutil@2.9.0.post0freezegun@1.5.5pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
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("click-pytest-freezegun-scheduler") (install), or see them on GitHub.
requirements.txt
click==8.5.0 python-dateutil==2.9.0.post0 freezegun==1.5.5 pytest==9.1.1
scheduler.py
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
import click
from dateutil.rrule import rrulestr
def next_runs(rule: str, tz_name: str, count: int = 3) -> list[datetime]:
tz = ZoneInfo(tz_name)
now = datetime.now(tz).replace(microsecond=0)
rs = rrulestr(rule, dtstart=now)
# rrule builds wall-clock datetimes, and a time inside a DST gap (02:30 on the
# spring-forward day) resolves to a real instant. Round-tripping through UTC
# makes that instant explicit and gives the correct local wall time.
return [dt.astimezone(UTC).astimezone(tz) for dt in rs.xafter(now, count=count)]
@click.command()
@click.argument("rule")
@click.option("--tz", default="UTC", show_default=True, help="IANA time zone name.")
@click.option("-n", "--count", default=3, show_default=True, type=click.IntRange(min=1))
def main(rule: str, tz: str, count: int) -> None:
"""Print the next run times for an RFC 5545 RRULE, e.g. "FREQ=DAILY;BYHOUR=9"."""
try:
runs = next_runs(rule, tz, count)
except (ValueError, KeyError) as exc:
raise click.UsageError(str(exc)) from exc
for run in runs:
click.echo(run.isoformat())
if __name__ == "__main__":
main()
tests/test_cli.py
import pytest
from click.testing import CliRunner
from freezegun import freeze_time
from scheduler import main, next_runs
NINE_AM = "FREQ=DAILY;BYHOUR=9;BYMINUTE=0"
def test_daily_nine_am_keeps_wall_clock_across_dst():
# 2024-03-09 12:00Z is 07:00 EST; US DST starts 2024-03-10.
with freeze_time("2024-03-09T12:00:00Z"):
runs = next_runs(NINE_AM, "America/New_York", count=2)
assert [r.isoformat() for r in runs] == [
"2024-03-09T09:00:00-05:00",
"2024-03-10T09:00:00-04:00",
]
def test_nonexistent_time_in_dst_gap_is_normalized():
# 02:30 does not exist on 2024-03-10 in New York; it resolves to 03:30 EDT.
with freeze_time("2024-03-09T12:00:00Z"):
runs = next_runs("FREQ=DAILY;BYHOUR=2;BYMINUTE=30", "America/New_York", count=2)
assert [r.isoformat() for r in runs] == [
"2024-03-10T03:30:00-04:00",
"2024-03-11T02:30:00-04:00",
]
def test_ambiguous_time_in_fall_back_uses_first_occurrence():
# 01:30 happens twice on 2024-11-03 in New York; fold=0 picks the EDT (first) one.
with freeze_time("2024-11-02T12:00:00Z"):
runs = next_runs("FREQ=DAILY;BYHOUR=1;BYMINUTE=30", "America/New_York", count=2)
assert [r.isoformat() for r in runs] == [
"2024-11-03T01:30:00-04:00",
"2024-11-04T01:30:00-05:00",
]
def test_cli_prints_runs_in_requested_zone():
runner = CliRunner()
with freeze_time("2024-03-09T12:00:00Z"):
result = runner.invoke(main, [NINE_AM, "--tz", "Europe/Berlin", "-n", "2"])
assert result.exit_code == 0, result.output
assert result.output.splitlines() == [
"2024-03-10T09:00:00+01:00",
"2024-03-11T09:00:00+01:00",
]
@pytest.mark.parametrize(
"args, message",
[
([NINE_AM, "--tz", "Europe/Atlantis"], "No time zone found"),
(["FREQ=NOPE", "--tz", "UTC"], "FREQ"),
],
)
def test_bad_input_is_a_usage_error(args, message):
runner = CliRunner()
with freeze_time("2024-03-09T12:00:00Z"):
result = runner.invoke(main, args)
assert result.exit_code == 2
assert message in result.output
pytest.ini
[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.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "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.