"""Lightweight natural-language time parser for schedule_recall."""
import re
from datetime import datetime, timedelta, timezone
def _try_iso(value: str) -> datetime | None:
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def _try_relative(value: str) -> datetime | None:
"""Parse strings like 'in 2 days', '3h 30m', '1d 2h 3m 4s'."""
val = value.strip().lower()
# 'in X days/hours/minutes/seconds'
m = re.match(
r"in\s+(\d+)\s+(day|days|hour|hours|minute|minutes|min|mins|second|seconds|sec|secs)s?",
val,
)
if m:
amount = int(m.group(1))
unit = m.group(2)
delta = _unit_to_delta(amount, unit)
if delta:
return datetime.now(timezone.utc) + delta
# Compact notation: '2d 6h 30m', '1d', '3h30m'
pattern = re.compile(r"(\d+)\s*(d|h|m|s)")
matches = pattern.findall(val)
if matches:
total_delta = timedelta()
for amount_str, unit in matches:
delta = _unit_to_delta(int(amount_str), unit)
if delta:
total_delta += delta
if total_delta.total_seconds() > 0:
return datetime.now(timezone.utc) + total_delta
return None
def _try_tomorrow(value: str) -> datetime | None:
"""Parse 'tomorrow at HH:MM' or 'tomorrow HH:MM'."""
val = value.strip().lower()
if not val.startswith("tomorrow"):
return None
# Extract time portion after 'tomorrow'
rest = val[8:].strip()
rest = rest.lstrip("at").strip()
for fmt in ("%H:%M", "%H.%M", "%I:%M %p", "%I%p"):
try:
t = datetime.strptime(rest, fmt).time()
now = datetime.now(timezone.utc)
target = datetime.combine(now.date() + timedelta(days=1), t)
return target.replace(tzinfo=timezone.utc)
except ValueError:
continue
return None
def _try_dateutil(value: str) -> datetime | None:
try:
from dateutil import parser as _dateutil_parser # type: ignore[import-untyped]
parsed = _dateutil_parser.parse(value)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed
except Exception:
return None
def _unit_to_delta(amount: int, unit: str) -> timedelta | None:
if unit in ("d", "day", "days"):
return timedelta(days=amount)
if unit in ("h", "hour", "hours"):
return timedelta(hours=amount)
if unit in ("m", "min", "mins", "minute", "minutes"):
return timedelta(minutes=amount)
if unit in ("s", "sec", "secs", "second", "seconds"):
return timedelta(seconds=amount)
return None
def parse_when(value: str) -> tuple[datetime | None, str | None]:
"""Parse a natural-language time string into a UTC datetime.
Returns (datetime, None) on success, (None, error_message) on failure.
"""
if not value or not value.strip():
return None, "Empty time string."
# 1. ISO datetime
result = _try_iso(value.strip())
if result:
if result.tzinfo is None:
result = result.replace(tzinfo=timezone.utc)
return result, None
# 2. Relative expressions
result = _try_relative(value.strip())
if result:
return result, None
# 3. Tomorrow at time
result = _try_tomorrow(value.strip())
if result:
return result, None
# 4. dateutil (optional soft dependency)
result = _try_dateutil(value.strip())
if result:
return result, None
return None, (
f"Could not parse '{value}'. "
"Supported formats: ISO datetime, relative like '2d 6h 30m' or 'in 3 hours', "
"or 'tomorrow at 09:00'."
)