"""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