123 lines
4.5 KiB
Python
123 lines
4.5 KiB
Python
"""Deterministic random fire-time computation for reminders.
|
|
|
|
Shared by remind_send.py (runtime) and remind_edit.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 _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
|