161 lines
5.9 KiB
Python
161 lines
5.9 KiB
Python
"""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
|