sched: add thin croniter calendar adapter with UTC occurrence sources (T01)

This commit is contained in:
lda
2026-09-08 10:02:58 +07:00 Verified
parent d5fa180984
commit 4e00a2c6b7
6 changed files with 495 additions and 0 deletions
+300
View File
@@ -0,0 +1,300 @@
"""Port of the calendar-probe Part A contract to the thin adapter.
Policy: croniter owns calendar calculation, including DST resolution.
The adapter converts now into the schedule zone, queries, converts back
to UTC, and applies no correction of its own. See
docs/superpowers/specs/2026-09-08-deployment-scheduling-design.md and the
implementation plan Gate 2.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from importlib.metadata import version as _pkg_version
from zoneinfo import ZoneInfo
import pytest
from wf_scheduling.calendar import (
CronSource,
InvalidScheduleDefinitionError,
OneShotSource,
ScheduleExhaustedError,
)
UTC = timezone.utc
HCMC = ZoneInfo("Asia/Ho_Chi_Minh")
NYC = ZoneInfo("America/New_York")
def utc(*args: int) -> datetime:
return datetime(*args, tzinfo=UTC)
def test_versions_pinned() -> None:
assert _pkg_version("croniter") == "6.2.4"
def test_utc_daily_next_is_strictly_increasing_and_unique() -> None:
src = CronSource("30 9 * * *", "UTC")
seen: set[datetime] = set()
prev: datetime | None = None
cursor = utc(2026, 9, 1, 0, 0, 0)
for _ in range(10):
nxt = src.next_after(cursor)
assert nxt is not None
assert nxt.tzinfo is not None
assert nxt.utcoffset() == timedelta(0)
assert nxt not in seen
seen.add(nxt)
if prev is not None:
assert nxt > prev
prev = nxt
cursor = nxt
assert len(seen) == 10
def test_hcmc_daily_converts_and_hourly_counts() -> None:
src = CronSource("30 9 * * *", "Asia/Ho_Chi_Minh")
nxt = src.next_after(datetime(2026, 9, 1, 0, 0, tzinfo=HCMC))
assert nxt is not None
assert (nxt.hour, nxt.minute) == (2, 30)
assert nxt.date().isoformat() == "2026-09-01"
for _label, start in (
("march", utc(2026, 3, 7, 17, 30, 0)),
("november", utc(2026, 10, 31, 17, 30, 0)),
):
hourly = CronSource("0 * * * *", "Asia/Ho_Chi_Minh")
cursor = start
count = 0
while True:
hit = hourly.next_after(cursor)
assert hit is not None
if hit >= start + timedelta(hours=48):
break
count += 1
assert count < 60
cursor = hit
assert count == 48
def test_expression_forms_wildcard_step_list_range() -> None:
cases = {
"* * * * *": (datetime(2026, 9, 8, 0, 0, tzinfo=UTC), "2026-09-08T00:01:00"),
"5/15 * * * *": (
datetime(2026, 9, 8, 0, 0, tzinfo=UTC),
"2026-09-08T00:05:00",
),
"0,30 1-2 * * *": (
datetime(2026, 9, 8, 0, 0, tzinfo=UTC),
"2026-09-08T01:00:00",
),
"*/20 1-3 * * *": (
datetime(2026, 9, 8, 1, 50, tzinfo=UTC),
"2026-09-08T02:00:00",
),
}
for expr, (start, want) in cases.items():
src = CronSource(expr, "UTC")
nxt = src.next_after(start)
assert nxt is not None
assert nxt.isoformat() == want + "+00:00"
def test_weekday_names_and_numbers_unix() -> None:
monday = datetime(2026, 9, 7, 0, 0, tzinfo=UTC)
cases = {
"0 12 * * 0": 6,
"0 12 * * 7": 6,
"0 12 * * 1": 0,
"0 12 * * mon": 0,
"0 12 * * sun": 6,
}
for expr, want_wd in cases.items():
nxt = CronSource(expr, "UTC").next_after(monday)
assert nxt is not None
assert nxt.weekday() == want_wd, expr
assert CronSource("0 12 * * 0", "UTC").next_after(monday) == CronSource(
"0 12 * * sun", "UTC"
).next_after(monday)
def test_dom_dow_day_or_true_is_selected() -> None:
start = datetime(2026, 9, 1, 0, 0, tzinfo=UTC)
src = CronSource("0 12 13 * fri", "UTC")
hits: list[str] = []
cursor = start
for _ in range(4):
nxt = src.next_after(cursor)
assert nxt is not None
hits.append(nxt.date().isoformat())
cursor = nxt
assert hits == ["2026-09-04", "2026-09-11", "2026-09-13", "2026-09-18"]
def test_zone_conversion_is_the_adapters_job() -> None:
src = CronSource("30 9 * * *", "Asia/Ho_Chi_Minh")
nxt = src.next_after(datetime(2026, 9, 1, 0, 0, tzinfo=HCMC))
assert nxt is not None
assert nxt.tzinfo is not None
assert nxt.utcoffset() == timedelta(0)
def test_gap_nonexistent_wall_times_resolve_forward() -> None:
src = CronSource("30 2 * * *", "America/New_York")
first = src.next_after(datetime(2026, 3, 7, 12, 0, tzinfo=NYC))
assert first is not None
assert first.isoformat() == "2026-03-08T07:00:00+00:00"
following = src.next_after(first)
assert following is not None
assert following.isoformat() == "2026-03-09T06:30:00+00:00"
def test_gap_per_minute_stream_jumps_forward() -> None:
src = CronSource("* * * * *", "America/New_York")
cursor = datetime(2026, 3, 8, 0, 0, tzinfo=NYC)
seq: list[datetime] = []
for _ in range(300):
nxt = src.next_after(cursor)
assert nxt is not None
seq.append(nxt)
cursor = nxt
assert all(b > a for a, b in zip(seq, seq[1:]))
assert len(set(seq)) == len(seq)
gap_day = [d for d in seq if d.astimezone(NYC).date().isoformat() == "2026-03-08"]
assert all(d.astimezone(NYC).hour != 2 for d in gap_day)
def test_gap_backward_queries_return_library_resolution() -> None:
src = CronSource("30 2 * * *", "America/New_York")
cases = {
"2026-03-08T12:00": "2026-03-08T07:00:00+00:00",
"2026-03-09T00:00": "2026-03-08T07:00:00+00:00",
"2026-03-07T12:00": "2026-03-07T07:30:00+00:00",
"2026-03-09T12:00": "2026-03-09T06:30:00+00:00",
}
for start_iso, want in cases.items():
start = datetime.fromisoformat(start_iso).replace(tzinfo=NYC)
got = src.prev_before(start)
assert got is not None
assert got.isoformat() == want, start_iso
def test_fold_repeated_times_are_distinct_utc() -> None:
src = CronSource("30 1 * * *", "America/New_York")
first = src.next_after(datetime(2026, 10, 31, 12, 0, tzinfo=NYC))
assert first is not None
assert first == utc(2026, 11, 1, 5, 30)
assert src.next_after(first) == utc(2026, 11, 1, 6, 30)
def test_fold_per_minute_unique_increasing() -> None:
src = CronSource("* * * * *", "America/New_York")
cursor = datetime(2026, 10, 31, 20, 0, tzinfo=NYC)
seq: list[datetime] = []
for _ in range(560):
nxt = src.next_after(cursor)
assert nxt is not None
seq.append(nxt)
cursor = nxt
assert all(b > a for a, b in zip(seq, seq[1:]))
assert len(set(seq)) == len(seq)
iso = {d.isoformat() for d in seq}
assert "2026-11-01T05:30:00+00:00" in iso
assert "2026-11-01T06:30:00+00:00" in iso
def test_latest_missed_after_years_of_downtime_is_bounded() -> None:
import time
src = CronSource("* * * * *", "UTC")
now = utc(2026, 9, 8, 12, 0, 0)
t0 = time.perf_counter()
latest = src.prev_before(now)
elapsed = time.perf_counter() - t0
assert latest is not None
assert elapsed < 5
assert latest <= now
assert (now - latest) < timedelta(minutes=2)
nyc_src = CronSource("* * * * *", "America/New_York")
latest_nyc = nyc_src.prev_before(datetime(2026, 9, 8, 12, 0, tzinfo=NYC))
assert latest_nyc is not None
assert latest_nyc.isoformat() == "2026-09-08T15:59:00+00:00"
def test_iteration_is_exclusive_both_directions() -> None:
# Exclusive get_next/get_prev alone does not include an occurrence equal
# to now: the adapter AND scheduler must handle exact due-time inclusion
# via the last-consumed watermark, not by assuming inclusivity.
src = CronSource("0 13 * * *", "UTC")
due = utc(2026, 9, 8, 13, 0, 0)
fwd = src.next_after(due)
back = src.prev_before(due)
assert fwd == utc(2026, 9, 9, 13, 0, 0)
assert back is not None
assert (back.year, back.month, back.day, back.hour, back.minute) == (
2026,
9,
7,
13,
0,
)
just_before = src.next_after(due - timedelta(seconds=1))
assert just_before == due
# Exact due-time inclusion through the adapter: prev_before just after due
# returns the due instant itself.
just_after = src.prev_before(due + timedelta(seconds=1))
assert just_after == due
def test_exact_due_time_inclusion_through_scheduler_watermark() -> None:
# A scheduler querying forward from the last-consumed instant and comparing
# catch-up results against the same watermark admits a due occurrence
# exactly once: forward from consumed (exclusive) finds due, and due is
# not <= consumed so it is eligible. Querying forward from due itself
# must not return due again.
src = CronSource("0 13 * * *", "UTC")
due = utc(2026, 9, 8, 13, 0, 0)
consumed = utc(2026, 9, 7, 13, 0, 0)
candidate = src.next_after(consumed)
assert candidate == due
assert candidate is not None and candidate > consumed
assert src.next_after(due) != due
def test_impossible_schedule_maps_to_exhausted() -> None:
import time
src = CronSource("0 12 30 2 *", "UTC")
start = time.perf_counter()
with pytest.raises(ScheduleExhaustedError):
src.next_after(utc(2026, 1, 1))
assert time.perf_counter() - start < 5
def test_bad_expressions_rejected_naive_rejected() -> None:
with pytest.raises(InvalidScheduleDefinitionError):
CronSource("nonsense", "UTC")
with pytest.raises(InvalidScheduleDefinitionError):
CronSource("* * * *", "UTC")
with pytest.raises(InvalidScheduleDefinitionError):
CronSource("* * * * * *", "UTC")
with pytest.raises(InvalidScheduleDefinitionError):
CronSource("* * * * *", "No/Such_Zone")
with pytest.raises(ValueError, match="aware"):
CronSource("* * * * *", "UTC").next_after(datetime(2026, 9, 8, 12, 0))
with pytest.raises(ValueError, match="aware"):
CronSource("* * * * *", "UTC").prev_before(datetime(2026, 9, 8, 12, 0))
def test_oneshot_requires_offset_and_returns_utc() -> None:
at = datetime(2026, 9, 8, 12, 0, tzinfo=timezone(timedelta(hours=2)))
src = OneShotSource(at)
assert src.next_after(utc(2026, 9, 8, 9, 0)) == utc(2026, 9, 8, 10, 0)
assert src.next_after(utc(2026, 9, 8, 10, 0)) is None
assert src.prev_before(utc(2026, 9, 8, 11, 0)) == utc(2026, 9, 8, 10, 0)
assert src.prev_before(utc(2026, 9, 8, 10, 0)) is None
with pytest.raises(InvalidScheduleDefinitionError):
OneShotSource(datetime(2026, 9, 8, 12, 0))
with pytest.raises(ValueError, match="aware"):
src.next_after(datetime(2026, 9, 8, 12, 0))