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
+160
View File
@@ -0,0 +1,160 @@
"""Thin croniter occurrence-source adapter.
croniter owns calendar behavior, including DST resolution for nonexistent
and repeated local times. The adapter converts the query instant into the
schedule's named zone, asks croniter for the next/previous occurrence, and
converts the result to UTC. It applies no calendar correction of its own.
Iteration is exclusive in both directions (a query from exactly a due
instant returns the neighboring occurrence), so callers query forward from
the last-consumed instant and compare catch-up results against the same
watermark: a due occurrence is admitted exactly once.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Protocol
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from croniter import CroniterBadCronError, CroniterBadDateError
from croniter import croniter as _Croniter
UTC = timezone.utc
class InvalidScheduleDefinitionError(ValueError):
"""A schedule definition is rejected (bad cron, bad zone, naive time)."""
class ScheduleExhaustedError(RuntimeError):
"""Calendar search exhausted: an impossible schedule with no occurrence."""
class OccurrenceSource(Protocol):
"""Due-instant source over aware datetimes, UTC at the boundary."""
def next_after(self, instant: datetime) -> datetime | None:
"""Return the first occurrence strictly after ``instant``."""
...
def prev_before(self, instant: datetime) -> datetime | None:
"""Return the latest occurrence strictly before ``instant``."""
...
def _require_aware(instant: datetime, *, op: str) -> datetime:
if instant.tzinfo is None or instant.utcoffset() is None:
raise ValueError(f"{op} requires an aware datetime with an offset")
return instant
def _ensure_utc_progress(
result_utc: datetime, bound_utc: datetime, *, forward: bool, op: str
) -> None:
if forward and not result_utc > bound_utc:
raise RuntimeError(f"{op} produced a non-progressing result")
if not forward and not result_utc < bound_utc:
raise RuntimeError(f"{op} produced a non-progressing result")
class CronSource:
"""Five-field Unix-cron source in one explicit IANA time zone.
Dialect: numeric ``0`` and ``7`` both mean Sunday (not Monday-first),
and day-of-month/day-of-week matching uses croniter's standard
``day_or=True`` (Unix OR), exposed explicitly.
"""
def __init__(self, expression: str, timezone_name: str) -> None:
fields = expression.split()
if len(fields) != 5:
raise InvalidScheduleDefinitionError(
f"cron expression must have exactly 5 fields, got {len(fields)}: "
f"{expression!r}"
)
try:
zone = ZoneInfo(timezone_name)
except (ZoneInfoNotFoundError, ValueError) as exc:
raise InvalidScheduleDefinitionError(
f"invalid time zone {timezone_name!r}"
) from exc
try:
_Croniter(expression, datetime(2026, 1, 1, tzinfo=zone), day_or=True)
except CroniterBadCronError as exc:
raise InvalidScheduleDefinitionError(
f"invalid cron expression {expression!r}: {exc}"
) from exc
self._expression = expression
self._timezone_name = timezone_name
self._zone = zone
@property
def expression(self) -> str:
"""Return the five-field cron expression."""
return self._expression
@property
def timezone_name(self) -> str:
"""Return the IANA time-zone name."""
return self._timezone_name
def next_after(self, instant: datetime) -> datetime | None:
"""Return the first occurrence strictly after ``instant`` in UTC."""
_require_aware(instant, op="next_after")
zoned = instant.astimezone(self._zone)
try:
iterator = _Croniter(self._expression, zoned, day_or=True)
result = iterator.get_next(datetime)
except CroniterBadDateError as exc:
raise ScheduleExhaustedError(
f"cron schedule {self._expression!r} is exhausted: {exc}"
) from exc
as_utc = result.astimezone(UTC)
_ensure_utc_progress(
as_utc, instant.astimezone(UTC), forward=True, op="next_after"
)
return as_utc
def prev_before(self, instant: datetime) -> datetime | None:
"""Return the latest occurrence strictly before ``instant`` in UTC."""
_require_aware(instant, op="prev_before")
zoned = instant.astimezone(self._zone)
try:
iterator = _Croniter(self._expression, zoned, day_or=True)
result = iterator.get_prev(datetime)
except CroniterBadDateError as exc:
raise ScheduleExhaustedError(
f"cron schedule {self._expression!r} is exhausted: {exc}"
) from exc
as_utc = result.astimezone(UTC)
_ensure_utc_progress(
as_utc, instant.astimezone(UTC), forward=False, op="prev_before"
)
return as_utc
class OneShotSource:
"""Single UTC instant source; naive timestamps are rejected."""
def __init__(self, at: datetime) -> None:
if at.tzinfo is None or at.utcoffset() is None:
raise InvalidScheduleDefinitionError(
"OneShotSource requires an aware datetime with an offset"
)
self._at = at.astimezone(UTC)
@property
def at(self) -> datetime:
"""Return the one-shot instant in UTC."""
return self._at
def next_after(self, instant: datetime) -> datetime | None:
"""Return the instant if strictly after, else None."""
_require_aware(instant, op="next_after")
return self._at if instant.astimezone(UTC) < self._at else None
def prev_before(self, instant: datetime) -> datetime | None:
"""Return the instant if strictly before, else None."""
_require_aware(instant, op="prev_before")
return self._at if instant.astimezone(UTC) > self._at else None