ProvenPin Install

croniter next run times as pendulum timezone-aware datetimes: working versions (tested 2026-10-16)

Compute the next cron fire times as tz-aware pendulum DateTimes, handling DST gaps and folds, with focused offline tests.

passed6 passed · 0 failed2026-10-16 00:56 UTC
Status
passed
Last passed
2026-10-16 00:56 UTC
Tests
6 / 6
Language
Python
Runtime
Python 3.13.16
pinned versions3
croniter@6.2.4pendulum@3.3.0pytest@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 38832319459

The code

Exactly the files that passed. Your agent can pull them with get_recipe("croniter-pendulum-next-run") (install), or see them on GitHub.

requirements.txt
croniter==6.2.4
pendulum==3.3.0
pytest==9.1.1
scheduler.py
from __future__ import annotations

import datetime

import pendulum
from croniter import croniter


def next_run(cron: str, after: pendulum.DateTime) -> pendulum.DateTime:
    """Next cron match strictly after `after`, as a tz-aware pendulum DateTime.

    croniter keeps `after.tzinfo` (so DST is handled) but always hands back a
    plain `datetime.datetime`; asking for `ret_type=pendulum.DateTime` does not
    change that, so we re-wrap it with `pendulum.instance`.
    """
    nxt = croniter(cron, after).get_next(datetime.datetime)
    return pendulum.instance(nxt)


def upcoming(
    cron: str, after: pendulum.DateTime, count: int
) -> list[pendulum.DateTime]:
    """The next `count` cron matches after `after`, in the same timezone."""
    it = croniter(cron, after)
    return [pendulum.instance(it.get_next(datetime.datetime)) for _ in range(count)]
tests/test_scheduler.py
import datetime

import pendulum
from croniter import croniter

from scheduler import next_run, upcoming

NY = "America/New_York"


def test_next_run_returns_tz_aware_pendulum_datetime():
    start = pendulum.datetime(2024, 1, 1, 8, 0, tz=NY)
    # croniter ignores non-datetime ret_type classes and returns a plain datetime
    raw = croniter("0 9 * * *", start).get_next(pendulum.DateTime)
    assert type(raw) is datetime.datetime and not isinstance(raw, pendulum.DateTime)

    result = next_run("0 9 * * *", start)
    assert isinstance(result, pendulum.DateTime)
    assert result.timezone_name == NY
    assert result == pendulum.datetime(2024, 1, 1, 9, 0, tz=NY)


def test_start_time_is_exclusive():
    start = pendulum.datetime(2024, 1, 1, 9, 0, tz=NY)
    assert next_run("0 9 * * *", start) == pendulum.datetime(2024, 1, 2, 9, 0, tz=NY)


def test_spring_forward_skips_missing_local_hour():
    start = pendulum.datetime(2024, 3, 9, 12, 0, tz=NY)
    result = next_run("0 2 * * *", start)
    assert result == pendulum.datetime(2024, 3, 10, 3, 0, tz=NY)
    assert result.tzname() == "EDT"


def test_fall_back_yields_both_folds():
    start = pendulum.datetime(2024, 11, 2, 12, 0, tz=NY)
    runs = upcoming("30 1 * * *", start, 2)
    assert [r.tzname() for r in runs] == ["EDT", "EST"]
    assert [r.fold for r in runs] == [0, 1]
    assert runs[0].timestamp() != runs[1].timestamp()


def test_weekday_cron_skips_the_weekend():
    friday = pendulum.datetime(2024, 1, 5, 10, 0, tz=NY)
    assert next_run("0 9 * * 1-5", friday) == pendulum.datetime(2024, 1, 8, 9, 0, tz=NY)


def test_cron_is_evaluated_in_the_base_timezone():
    start = pendulum.datetime(2024, 1, 1, 0, 0, tz="UTC")
    tokyo = next_run("0 9 * * *", start).in_timezone("Asia/Tokyo")
    assert tokyo.timezone_name == "Asia/Tokyo"
    assert (tokyo.hour, tokyo.minute) == (18, 0)
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.