sched: add thin croniter calendar adapter with UTC occurrence sources (T01)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user