ProvenPin Install

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 11:52 UTC
Status
passed
Last passed
2026-10-15 11:52 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 38730348195

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

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.