feat(cli): add natural language date parsing via dateparser

- 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)
This commit is contained in:
Jef Roosens 2026-05-22 10:57:16 +02:00
parent 29698b1241
commit 615bfe30e0
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
6 changed files with 207 additions and 13 deletions

View file

@ -51,12 +51,12 @@ def build_parser() -> argparse.ArgumentParser:
)
parser.add_argument(
"day",
nargs="?",
nargs="*",
help=(
"Day to extract timesheets for. Accepts YYYY-MM-DD, MM-DD, or DD-MM "
"(- or / as separator). Defaults to today."
"Day to extract timesheets for. Accepts YYYY-MM-DD, MM-DD, DD-MM, "
"or a natural language expression like 'yesterday' or '3 days ago'. "
"Defaults to today."
),
default=None,
)
parser.add_argument(
"--map",
@ -93,11 +93,13 @@ def main() -> None:
config_path = args.config if args.config is not None else find_default_config()
config = load_config(config_path)
if args.day is None:
day_input = " ".join(args.day) if args.day else None
if day_input is None:
target_date = date.today()
else:
try:
target_date = parse_date_arg(args.day)
target_date = parse_date_arg(day_input)
except AmbiguousDateError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

View file

@ -63,10 +63,29 @@ def parse_date_arg(value: str) -> date:
raise ValueError(f"Invalid date: {value!r}")
return result
raise ValueError(
f"Unrecognised date format: {value!r}. "
"Expected YYYY-MM-DD, MM-DD, or DD-MM (- or / as separator)."
# 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: