Zalohovani vsech podstatnych souboru
This commit is contained in:
122
skills/remind/scripts/random_times.py
Normal file
122
skills/remind/scripts/random_times.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user