docs: specify deployment scheduling and verify implementation plan
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
# 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()}")
|
||||
Reference in New Issue
Block a user