- Add _parse_natural() to utils.py using dateparser as a fallback when structured date formats (YYYY-MM-DD, MM-DD, DD-MM) don't match - Supports expressions like 'today', 'yesterday', 'monday', '3 days ago' - Change day argument to nargs='*' and join tokens so unquoted multi-word expressions like: uv run timesheets 3 days ago work correctly - Pin dateparser to English to avoid locale-dependent behaviour - Update tests to cover natural language cases and fix test_last_monday (dateparser does not support 'last monday'; use 'monday' instead)
130 lines
4.2 KiB
Python
130 lines
4.2 KiB
Python
import re
|
|
from datetime import date
|
|
|
|
# Separator is either - or /
|
|
_SEP = r"[-/]"
|
|
_DATE_PATTERNS = [
|
|
# YYYY-MM-DD or YYYY/MM/DD
|
|
re.compile(r"^(\d{4})" + _SEP + r"(\d{1,2})" + _SEP + r"(\d{1,2})$"),
|
|
# Two-part: first two digits, sep, last two digits
|
|
re.compile(r"^(\d{1,2})" + _SEP + r"(\d{1,2})$"),
|
|
]
|
|
|
|
|
|
class AmbiguousDateError(ValueError):
|
|
"""Raised when a two-part date is valid as both MM-DD and DD-MM."""
|
|
|
|
|
|
def _valid_date(year: int, month: int, day: int) -> date | None:
|
|
"""Return a date if the triple is valid, else None."""
|
|
try:
|
|
return date(year, month, day)
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def parse_date_arg(value: str) -> date:
|
|
"""
|
|
Parse a flexible date string into a date object.
|
|
|
|
Accepted formats (separator is - or /):
|
|
YYYY-MM-DD unambiguous
|
|
MM-DD month then day; year assumed to be current year
|
|
DD-MM day then month; year assumed to be current year
|
|
|
|
If a two-part value is valid as both MM-DD and DD-MM, raises
|
|
AmbiguousDateError. If it is valid as neither, raises ValueError.
|
|
"""
|
|
value = value.strip()
|
|
today = date.today()
|
|
|
|
# Try YYYY-MM-DD first
|
|
m = re.match(r"^(\d{4})[-/](\d{1,2})[-/](\d{1,2})$", value)
|
|
if m:
|
|
y, a, b = int(m.group(1)), int(m.group(2)), int(m.group(3))
|
|
d = _valid_date(y, a, b)
|
|
if d is None:
|
|
raise ValueError(f"Invalid date: {value!r}")
|
|
return d
|
|
|
|
# Try two-part MM-DD / DD-MM
|
|
m = re.match(r"^(\d{1,2})[-/](\d{1,2})$", value)
|
|
if m:
|
|
a, b = int(m.group(1)), int(m.group(2))
|
|
as_md = _valid_date(today.year, a, b) # interpret as MM-DD
|
|
as_dm = _valid_date(today.year, b, a) # interpret as DD-MM
|
|
|
|
if as_md is not None and as_dm is not None and as_md != as_dm:
|
|
raise AmbiguousDateError(
|
|
f"{value!r} is ambiguous: could be {as_md} (MM-DD) or {as_dm} (DD-MM)"
|
|
)
|
|
result = as_md or as_dm
|
|
if result is None:
|
|
raise ValueError(f"Invalid date: {value!r}")
|
|
return result
|
|
|
|
# Nothing matched structured formats — try natural language
|
|
return _parse_natural(value)
|
|
|
|
|
|
def _parse_natural(value: str) -> date:
|
|
"""
|
|
Parse a natural language date string using dateparser.
|
|
Raises ValueError if the string cannot be interpreted.
|
|
"""
|
|
import dateparser
|
|
|
|
result = dateparser.parse(
|
|
value,
|
|
languages=["en"],
|
|
settings={"PREFER_DATES_FROM": "past", "RETURN_AS_TIMEZONE_AWARE": False},
|
|
)
|
|
if result is None:
|
|
raise ValueError(
|
|
f"Could not interpret {value!r} as a date. "
|
|
"Try a structured format like '2026-05-22' or '05-22', "
|
|
"or a relative expression like 'today', 'yesterday', 'monday', or '3 days ago'."
|
|
)
|
|
return result.date()
|
|
|
|
|
|
def parse_duration(duration_str: str) -> float:
|
|
"""Convert HH:MM duration string to decimal hours."""
|
|
duration_str = duration_str.strip()
|
|
match = re.match(r"^(\d+):(\d{2})$", duration_str)
|
|
if not match:
|
|
raise ValueError(f"Invalid duration format: {duration_str!r}")
|
|
return int(match.group(1)) + int(match.group(2)) / 60.0
|
|
|
|
|
|
def duration_from_start_end(start_str: str, end_str: str) -> float:
|
|
"""Calculate duration in decimal hours from two HH:MM time strings."""
|
|
|
|
def to_minutes(t: str) -> int:
|
|
match = re.match(r"^(\d+):(\d{2})$", t.strip())
|
|
if not match:
|
|
raise ValueError(f"Invalid time format: {t!r}")
|
|
return int(match.group(1)) * 60 + int(match.group(2))
|
|
|
|
start_minutes = to_minutes(start_str)
|
|
end_minutes = to_minutes(end_str)
|
|
if end_minutes < start_minutes:
|
|
end_minutes += 24 * 60 # midnight rollover
|
|
return (end_minutes - start_minutes) / 60.0
|
|
|
|
|
|
def decimal_to_hhmm(hours: float) -> str:
|
|
"""Convert decimal hours to a HH:MM string."""
|
|
total_minutes = round(hours * 60)
|
|
h, m = divmod(total_minutes, 60)
|
|
return f"{h:02d}:{m:02d}"
|
|
|
|
|
|
def strip_markdown_link(text: str) -> str:
|
|
"""Strip markdown link syntax [label](url), keeping only the label."""
|
|
return re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", text)
|
|
|
|
|
|
def format_date(d: date) -> str:
|
|
"""Format date as DD/MM/YY."""
|
|
return d.strftime("%d/%m/%y")
|