Files
lda-wf/probes/deployment_scheduling_verify/test_calendar_probe.py
T

414 lines
17 KiB
Python

# DISPOSABLE CALENDAR-LIBRARY PROBE — NOT PRODUCTION CODE.
# See README.md in this directory. Requires croniter==6.2.4 and tzdata
# (plus apscheduler==3.11.0 for the Part B rejected-candidate evidence)
# on Python 3.14 (isolated env, not repo env).
"""Pin the croniter 6.2.4 calendar contract for deployment scheduling.
Policy: croniter owns calendar calculation, including DST resolution.
The adapter consumes it thinly — convert the query instant into the
schedule's named zone, ask for the next/previous occurrence, convert
the result to UTC — and applies no correction of its own. Each test
prints OBSERVED lines pinning 6.2.4 behavior. After any
calendar-dependency upgrade a failure IS the finding: re-probe, do not
hand-roll around it.
Part A pins the chosen-library contract (shipping acceptance).
Part B preserves rejected-candidate evidence (NOT acceptance).
Run:
uv run --project <isolated-probe-project> python -m pytest <this file>
-q -p no:cacheprovider -o addopts=""
"""
from __future__ import annotations
import time
from datetime import datetime, timedelta, timezone
import pytest
# This probe runs ONLY in the isolated calendar env (see README.md).
# Skip — do not error — when collected by a default repo test run.
pytest.importorskip("apscheduler", reason="isolated calendar-probe env only")
pytest.importorskip("croniter", reason="isolated calendar-probe env only")
from apscheduler.triggers.cron import CronTrigger # noqa: E402 (Part B only)
from croniter import ( # noqa: E402
CroniterBadCronError,
CroniterBadDateError,
)
from croniter import (
croniter as Croniter,
)
try: # noqa: E402
from zoneinfo import ZoneInfo
except ImportError: # pragma: no cover
from backports.zoneinfo import ZoneInfo # type: ignore[no-redef]
from importlib.metadata import version as _pkg_version # noqa: E402
import apscheduler # noqa: E402
CRONITER_VERSION = _pkg_version("croniter")
print(f"croniter=={CRONITER_VERSION}")
print(f"apscheduler=={apscheduler.__version__} (rejected candidate, Part B only)")
UTC = timezone.utc
HCMC = ZoneInfo("Asia/Ho_Chi_Minh")
NYC = ZoneInfo("America/New_York")
# 2026 DST transitions (US): spring forward 2026-03-08 02:00 -> 03:00,
# fall back 2026-11-01 02:00 -> 01:00.
def utc(*args) -> datetime:
return datetime(*args, tzinfo=UTC)
# ---------------------------------------------------------------------------
# Part A — chosen-library contract (croniter; shipping acceptance)
# ---------------------------------------------------------------------------
def test_versions_pinned():
print(f"OBSERVED croniter version={CRONITER_VERSION}")
print(f"OBSERVED apscheduler.__version__={apscheduler.__version__}")
# Exact pin: a calendar-dependency upgrade must fail here deliberately,
# forcing a re-probe before any pin update.
assert CRONITER_VERSION == "6.2.4"
assert apscheduler.__version__.startswith("3.")
def test_utc_daily_next_is_strictly_increasing_and_unique():
it = Croniter("30 9 * * *", utc(2026, 9, 1, 0, 0, 0))
seen: set[datetime] = set()
prev = None
for _ in range(10):
nxt = it.get_next(datetime)
assert nxt.tzinfo is not None
as_utc = nxt.astimezone(UTC)
assert as_utc not in seen, f"duplicate UTC instant {as_utc}"
seen.add(as_utc)
if prev is not None:
assert as_utc > prev, "not strictly increasing"
print(f"OBSERVED utc-daily next={as_utc.isoformat()}")
prev = as_utc
assert len(seen) == 10
def test_hcmc_daily_converts_and_hourly_counts():
# Asia/Ho_Chi_Minh is UTC+7 with no DST. 09:30 local == 02:30 UTC.
nxt = Croniter("30 9 * * *", datetime(2026, 9, 1, 0, 0, tzinfo=HCMC)).get_next(
datetime
)
as_utc = nxt.astimezone(UTC)
print(f"OBSERVED hcmc next local={nxt.isoformat()} utc={as_utc.isoformat()}")
assert (as_utc.hour, as_utc.minute) == (2, 30)
assert as_utc.date().isoformat() == "2026-09-01"
# No-DST zone: hourly occurrence count over two 48-hour windows
# (March and November, starting off-tick) must be exactly 48 each.
for label, start in (
("march", utc(2026, 3, 7, 17, 30, 0)),
("november", utc(2026, 10, 31, 17, 30, 0)),
):
it = Croniter("0 * * * *", start.astimezone(HCMC))
count = 0
while True:
hit = it.get_next(datetime).astimezone(UTC)
if hit >= start + timedelta(hours=48):
break
count += 1
assert count < 60
print(f"OBSERVED hcmc hourly count window={label} count={count}")
assert count == 48, f"{label}: no-DST zone must yield exactly 48, got {count}"
def test_expression_forms_wildcard_step_list_range():
"""Wildcards, steps, lists, and ranges resolve through plain
get_next on an ordinary day (no DST involved)."""
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():
nxt = Croniter(expr, start).get_next(datetime)
print(
f"OBSERVED croniter {expr!r} from {start.isoformat()} -> {nxt.isoformat()}"
)
assert nxt.astimezone(UTC).isoformat() == want + "+00:00"
def test_weekday_names_and_numbers_unix():
"""Pin croniter 6.2.4 weekday dialect (re-probe on upgrade). Unix
convention: numeric 0 AND 7 mean Sunday; 1/mon mean Monday."""
monday = datetime(2026, 9, 7, 0, 0, tzinfo=UTC) # a Monday
cases = {
"0 12 * * 0": 6, # Sunday
"0 12 * * 7": 6, # Sunday (alias)
"0 12 * * 1": 0, # Monday
"0 12 * * mon": 0,
"0 12 * * sun": 6,
}
for expr, want_wd in cases.items():
nxt = Croniter(expr, monday).get_next(datetime)
print(
f"OBSERVED croniter {expr!r} -> {nxt.date().isoformat()} weekday={nxt.weekday()}"
)
assert nxt.weekday() == want_wd, f"{expr}: want weekday={want_wd}"
assert Croniter("0 12 * * 0", monday).get_next(datetime) == Croniter(
"0 12 * * sun", monday
).get_next(datetime)
def test_dom_dow_day_or_true_is_selected():
"""Day-of-month/day-of-week uses croniter's standard day_or=True
(Unix OR). The alternative (AND) is pinned for reference only."""
start = datetime(2026, 9, 1, 0, 0, tzinfo=UTC)
it_or = Croniter("0 12 13 * fri", start, day_or=True)
hits_or = [it_or.get_next(datetime).date().isoformat() for _ in range(4)]
print(f"OBSERVED croniter day_or=True hits={hits_or}")
# Fridays plus the 13th (a Sunday): classic Unix OR.
assert hits_or == ["2026-09-04", "2026-09-11", "2026-09-13", "2026-09-18"]
it_and = Croniter("0 12 13 * fri", start, day_or=False)
first_and = it_and.get_next(datetime).date()
print(
f"OBSERVED croniter day_or=False first={first_and.isoformat()} (not selected)"
)
assert (first_and.day, first_and.weekday()) == (13, 4)
def test_zone_conversion_is_the_callers_job():
"""croniter iterates in the tz of the supplied datetime and performs
no conversion: a UTC start yields UTC results. The adapter converts
now into the schedule zone before querying and back to UTC after."""
utc_start = Croniter(
"30 2 * * *", datetime(2026, 3, 7, 12, 0, tzinfo=UTC)
).get_next(datetime)
print(
f"OBSERVED croniter utc-start gap next={utc_start.isoformat()} (no zone conversion)"
)
assert utc_start.tzinfo is UTC
hcmc_start = Croniter(
"30 9 * * *", datetime(2026, 9, 1, 0, 0, tzinfo=HCMC)
).get_next(datetime)
print(f"OBSERVED croniter hcmc-start next={hcmc_start.isoformat()}")
assert hcmc_start.utcoffset() == timedelta(hours=7)
def test_gap_nonexistent_wall_times_resolve_forward():
"""Observed 6.2.4 gap behavior: daily 02:30 on 2026-03-08 (a wall
time that never existed in America/New_York) resolves to 03:00-04:00
the same day. That resolution IS the occurrence — there is no
separate validity concept and no day is skipped over."""
first = Croniter("30 2 * * *", datetime(2026, 3, 7, 12, 0, tzinfo=NYC)).get_next(
datetime
)
print(f"OBSERVED croniter gap-day daily-0230 resolves={first.isoformat()}")
assert first.isoformat() == "2026-03-08T03:00:00-04:00"
following = Croniter("30 2 * * *", first).get_next(datetime)
print(f"OBSERVED croniter gap following={following.isoformat()}")
assert following.isoformat() == "2026-03-09T02:30:00-04:00"
def test_gap_per_minute_stream_jumps_forward():
"""Per-minute iteration across the spring gap jumps 01:59 EST
straight to 03:00 EDT: no 02:xx wall times are emitted, UTC is
strictly increasing and unique."""
it = Croniter("* * * * *", datetime(2026, 3, 8, 0, 0, tzinfo=NYC))
seq = [it.get_next(datetime) for _ in range(300)]
us = [d.astimezone(UTC) for d in seq]
assert all(b > a for a, b in zip(us, us[1:]))
assert len(set(us)) == len(us)
gap_day = [d for d in seq if d.date().isoformat() == "2026-03-08"]
assert all(d.hour != 2 for d in gap_day), "no 02:xx wall times emitted"
assert "2026-03-08T01:59:00-05:00" in (d.isoformat() for d in gap_day)
assert "2026-03-08T03:00:00-04:00" in (d.isoformat() for d in gap_day)
print(
"OBSERVED per-minute gap jump 01:59-05:00 -> 03:00-04:00, "
f"{len(gap_day)} gap-day hits"
)
def test_gap_backward_queries_return_library_resolution():
"""Backward queries resolve the same way: get_prev on the gap day
returns the forward-resolved 03:00-04:00, not a skipped-over day.
Latest-missed on a gap day is that resolved instant."""
cases = {
"2026-03-08T12:00": "2026-03-08T03:00:00-04:00",
"2026-03-09T00:00": "2026-03-08T03:00:00-04:00",
"2026-03-07T12:00": "2026-03-07T02:30:00-05:00",
"2026-03-09T12:00": "2026-03-09T02:30:00-04:00",
}
for start_iso, want in cases.items():
start = datetime.fromisoformat(start_iso).replace(tzinfo=NYC)
got = Croniter("30 2 * * *", start).get_prev(datetime)
print(f"OBSERVED croniter gap get_prev from {start_iso} -> {got.isoformat()}")
assert got.isoformat() == want
def test_fold_repeated_times_are_distinct_utc():
"""Both 01:30s on 2026-11-01 occur as distinct UTC instants
(05:30Z EDT, then 06:30Z EST)."""
it = Croniter("30 1 * * *", datetime(2026, 10, 31, 12, 0, tzinfo=NYC))
first = it.get_next(datetime)
second = it.get_next(datetime)
print(
f"OBSERVED croniter fold first={first.isoformat()} second={second.isoformat()}"
)
assert first.astimezone(UTC) == utc(2026, 11, 1, 5, 30)
assert second.astimezone(UTC) == utc(2026, 11, 1, 6, 30)
def test_fold_per_minute_unique_increasing():
"""Per-minute iteration across the fall fold emits both fold hours
with strictly increasing unique UTC instants."""
it = Croniter("* * * * *", datetime(2026, 10, 31, 20, 0, tzinfo=NYC))
seq = [it.get_next(datetime) for _ in range(560)]
us = [d.astimezone(UTC) for d in seq]
print(f"OBSERVED croniter fold-span count={len(us)} last={us[-1].isoformat()}")
assert all(b > a for a, b in zip(us, us[1:])), "must be strictly increasing"
assert len(set(us)) == len(us), "UTC identities must be unique"
iso = {d.isoformat() for d in us}
assert "2026-11-01T05:30:00+00:00" in iso, "first 01:30 (EDT) must occur"
assert "2026-11-01T06:30:00+00:00" in iso, "second 01:30 (EST) must occur"
def test_latest_missed_after_years_of_downtime():
"""Per-minute schedule, ~3 years of downtime: get_prev answers the
latest missed occurrence directly — no enumeration of missed years."""
now = utc(2026, 9, 8, 12, 0, 0)
t0 = time.perf_counter()
latest = Croniter("* * * * *", now).get_prev(datetime)
elapsed = time.perf_counter() - t0
print(
f"OBSERVED croniter get_prev({now.isoformat()})={latest.isoformat()} "
f"elapsed={elapsed:.4f}s"
)
assert latest.tzinfo is not None, "get_prev must preserve tz-awareness"
assert elapsed < 5, "latest-missed lookup must be bounded"
assert latest <= now
assert (now - latest) < timedelta(minutes=2)
now_nyc = datetime(2026, 9, 8, 12, 0, tzinfo=NYC)
latest_nyc = Croniter("* * * * *", now_nyc).get_prev(datetime)
print(
f"OBSERVED croniter nyc get_prev now={now_nyc.isoformat()} "
f"latest={latest_nyc.isoformat()}"
)
assert latest_nyc.tzinfo is not None
assert latest_nyc <= now_nyc
assert (now_nyc - latest_nyc) < timedelta(minutes=2)
assert latest_nyc.astimezone(UTC).isoformat() == "2026-09-08T15:59:00+00:00"
def test_iteration_is_exclusive_both_directions():
"""Both get_next and get_prev are exclusive of their start: a query
from exactly a due instant returns the neighboring occurrence, not
the instant itself. Adapter consequence (T01, not enforced here):
query forward from the last-consumed instant and compare catch-up
results against the same watermark, so a due occurrence is admitted
exactly once — neither missed by exclusivity nor admitted twice."""
due = utc(2026, 9, 8, 13, 0, 0)
fwd = Croniter("0 13 * * *", due).get_next(datetime)
back = Croniter("0 13 * * *", due).get_prev(datetime)
print(f"OBSERVED exclusive get_next(due)={fwd.isoformat()}")
print(f"OBSERVED exclusive get_prev(due)={back.isoformat()}")
assert fwd.astimezone(UTC) == utc(2026, 9, 9, 13, 0, 0)
assert (back.year, back.month, back.day, back.hour, back.minute) == (
2026,
9,
7,
13,
0,
)
just_before = Croniter("0 13 * * *", due - timedelta(seconds=1)).get_next(datetime)
assert just_before.astimezone(UTC) == due, "a tick just before due admits it"
def test_impossible_schedule_raises_promptly():
"""February 30th never occurs: the library raises its documented
exhaustion error promptly instead of searching forever. The adapter
maps this to exhausted — exhaustion must never look like progress."""
start = time.perf_counter()
with pytest.raises(CroniterBadDateError):
Croniter("0 12 30 2 *", utc(2026, 1, 1)).get_next(datetime)
elapsed = time.perf_counter() - start
print(
f"OBSERVED impossible-schedule raised CroniterBadDateError elapsed={elapsed:.3f}s"
)
assert elapsed < 5, f"search must be bounded, took {elapsed:.1f}s"
def test_bad_expressions_rejected_naive_passes_through():
"""Malformed expressions fail at construction with a documented
error. Naive datetimes are NOT rejected by the library — they pass
straight through — so the adapter rejects naive itself per spec."""
with pytest.raises(CroniterBadCronError):
Croniter("nonsense", utc(2026, 1, 1))
print("OBSERVED malformed expression rejected with CroniterBadCronError")
naive = Croniter("* * * * *", datetime(2026, 9, 8, 12, 0)).get_next(datetime)
print(f"OBSERVED naive start passes through tzinfo={naive.tzinfo}")
assert naive.tzinfo is None
# ---------------------------------------------------------------------------
# Part B — rejected-candidate evidence (APScheduler; NOT acceptance)
# ---------------------------------------------------------------------------
@pytest.mark.xfail(
strict=True,
reason="REJECTED CANDIDATE: APScheduler 3.11.0 fabricates a phantom "
"02:30-05:00 for the DST gap. Kept as evidence for the rejection; "
"the chosen library's gap behavior is pinned in Part A. Strict so "
"re-opening the candidacy fails loudly. See implementation plan.",
)
def test_rejected_candidate_gap_phantom():
# 02:30 does not exist in America/New_York on 2026-03-08. The
# disqualifier is the phantom itself: whatever the library returns
# must not be a nonexistent wall time.
trig = CronTrigger(hour=2, minute=30, second=0, timezone="America/New_York")
nxt = trig.get_next_fire_time(None, utc(2026, 3, 7, 12, 0, 0))
assert nxt is not None
local = nxt.astimezone(NYC)
print(f"OBSERVED apscheduler dst-gap next local={local.isoformat()}")
assert not (
(local.year, local.month, local.day) == (2026, 3, 8) and local.hour == 2
), f"library must not fabricate a nonexistent wall time, got {local.isoformat()}"
@pytest.mark.xfail(
strict=True,
reason="REJECTED CANDIDATE: APScheduler 3.11.0 replays 05:01Z-06:00Z "
"(~59 past minutes) after the fall fold, duplicating UTC occurrence "
"identities. Kept as evidence for the rejection; the chosen "
"library's fold behavior is pinned in Part A. See implementation plan.",
)
def test_rejected_candidate_fold_replay():
trig = CronTrigger(minute="*", second=0, timezone="America/New_York")
now = utc(2026, 11, 1, 0, 0, 0)
prev = None
seen: set[str] = set()
last: datetime | None = None
for _ in range(450): # spans past 07:00Z: covers BOTH fold hours
nxt = trig.get_next_fire_time(prev, now)
assert nxt is not None
as_utc = nxt.astimezone(UTC)
ident = f"sched-1|{as_utc.isoformat()}"
assert ident not in seen, f"duplicate occurrence identity {ident}"
seen.add(ident)
if last is not None:
assert as_utc > last, "occurrences must be strictly increasing"
last = as_utc
prev, now = nxt, nxt
print(f"OBSERVED fold-span count={len(seen)} unique, last={last.isoformat()}")