"""Deterministic random fire-time computation for reminders. Shared by remind_send.py (runtime) and remind_cli.py (validation). Stdlib only, so it imports cleanly regardless of the caller's uv/PEP 723 environment. A reminder's `random` block produces `times_per_day` fire times inside a daily `window`, spaced at least MIN_GAP_MIN apart. The times are random but fully determined by (date, text): any script computing them for the same day gets the same result, so no state needs to be persisted. """ from __future__ import annotations import random from datetime import date, datetime, time MIN_GAP_MIN = 15 # minimum gap between fire times in minutes; tune here def compute_fire_times(target_date: date, text: str, cfg: dict) -> list[datetime]: """Deterministic fire times for one day. Returns [] when the day falls outside the days/from/until filters. Raises ValueError on a malformed config (bad window, days, dates, or when the requested count cannot fit the window with MIN_GAP_MIN spacing) — these are structural and validated before any date filter, so the same call validates a config regardless of the date passed in. """ count = _parse_count(cfg.get("times_per_day")) start, end = parse_window(cfg.get("window")) day_set = _parse_days(cfg["days"]) if cfg.get("days") is not None else None from_date = _parse_date(cfg["from"]) if cfg.get("from") is not None else None until_date = _parse_date(cfg["until"]) if cfg.get("until") is not None else None total = end - start required = (count - 1) * MIN_GAP_MIN if required > total: raise ValueError( f"{count} times with a {MIN_GAP_MIN}-min gap need {required} min, " f"but the window is only {total} min wide" ) if from_date is not None and target_date < from_date: return [] if until_date is not None and target_date > until_date: return [] if day_set is not None and _cron_weekday(target_date) not in day_set: return [] slack = total - required rnd = random.Random(f"{target_date.isoformat()}|{text}") offsets = sorted(rnd.randint(0, slack) for _ in range(count)) minutes = [start + offset + index * MIN_GAP_MIN for index, offset in enumerate(offsets)] return [datetime.combine(target_date, _minute_to_time(m)) for m in minutes] def _parse_count(raw: object) -> int: if not isinstance(raw, int) or isinstance(raw, bool) or raw < 1: raise ValueError(f"times_per_day must be an int >= 1, got {raw!r}") return raw def parse_window(raw: object) -> tuple[int, int]: if not isinstance(raw, str) or "-" not in raw: raise ValueError(f"window must be 'HH:MM-HH:MM', got {raw!r}") start_str, end_str = raw.split("-", 1) start = _hhmm_to_minutes(start_str.strip()) end = _hhmm_to_minutes(end_str.strip()) if start >= end: raise ValueError(f"window start must be before end: {raw!r}") return start, end def _hhmm_to_minutes(value: str) -> int: parts = value.split(":") if len(parts) != 2: raise ValueError(f"invalid time {value!r}, expected HH:MM") hours, minutes = int(parts[0]), int(parts[1]) if not (0 <= hours < 24 and 0 <= minutes < 60): raise ValueError(f"time out of range: {value!r}") return hours * 60 + minutes def minutes_to_hhmm(total: int) -> str: """Format a minute offset (e.g. 570) as HH:MM (e.g. '09:30').""" return f"{total // 60:02d}:{total % 60:02d}" def _minute_to_time(total_minutes: int) -> time: return time(total_minutes // 60, total_minutes % 60) def _parse_date(raw: object) -> date: if isinstance(raw, datetime): return raw.date() if isinstance(raw, date): return raw return date.fromisoformat(str(raw)) def _parse_days(spec: object) -> set[int]: """Parse a cron day-of-week spec into a set of cron weekdays (0/7=Sun, 1=Mon..6=Sat).""" text = str(spec).strip() if text == "*": return set(range(7)) result: set[int] = set() for part in text.split(","): part = part.strip() if "-" in part: low_str, high_str = part.split("-", 1) low, high = int(low_str), int(high_str) result.update(_normalize_dow(day) for day in range(low, high + 1)) else: result.add(_normalize_dow(int(part))) return result def _normalize_dow(value: int) -> int: """cron allows 7 for Sunday; normalize it to 0.""" if not 0 <= value <= 7: raise ValueError(f"day-of-week out of range (0-7): {value}") return 0 if value == 7 else value def _cron_weekday(target: date) -> int: """Map Python weekday (Mon=0..Sun=6) to cron weekday (Sun=0, Mon=1..Sat=6).""" return (target.weekday() + 1) % 7