habit_tracker / src /utils /command_validation.py
GitHub Action Sync
Automated sync from GitHub
625714b
Raw
History Blame Contribute Delete
7.66 kB
from __future__ import annotations
import json
import shlex
from datetime import datetime, time
from typing import Any
from .exceptions import CommandError
GOAL_FLAG_MAP = {
"name": "name",
"n": "name",
"description": "description",
"desc": "description",
"status": "status",
"s": "status",
}
DAYLOG_FLAG_MAP = {
"date": "log_date",
"d": "log_date",
"bed": "bed_time",
"b": "bed_time",
"wake": "wake_time",
"w": "wake_time",
"sleep": "sleep_time",
"s": "sleep_time",
"sleephr": "sleep_duration_hours",
"sh": "sleep_duration_hours",
"totalcal": "total_calories",
"tc": "total_calories",
"totalprod": "total_productivity_min",
"tp": "total_productivity_min",
"note": "notes",
"n": "notes",
}
ACTIVITY_FLAG_MAP = {
"goal": "goal_name",
"g": "goal_name",
"date": "entry_date",
"d": "entry_date",
"name": "activity_type_name",
"n": "activity_type_name",
"type": "category",
"t": "category",
"status": "status",
"s": "status",
"time": "occurred_at",
"ti": "occurred_at",
"min": "duration_min",
"m": "duration_min",
"place": "location",
"p": "location",
"quan": "quantity_text",
"q": "quantity_text",
"what": "what_i_did",
"w": "what_i_did",
"result": "result_text",
"r": "result_text",
"note": "note",
"no": "note",
"detail": "details",
"de": "details",
}
def _parse_bool(value: Any) -> bool:
if isinstance(value, bool):
return value
str_value = str(value).strip().lower()
if str_value in ("true", "1", "yes"):
return True
elif str_value in ("false", "0", "no"):
return False
else:
raise CommandError(400, "invalid_field_type", f"Invalid boolean value '{value}'. Expected true/false, 1/0, or yes/no.")
def _parse_date(value: Any):
try:
return datetime.strptime(str(value), "%Y-%m-%d").date()
except ValueError as exc:
raise CommandError(400, "invalid_field_type", f"Invalid date value '{value}'. Expected YYYY-MM-DD.") from exc
def _parse_time(value: Any) -> time:
for fmt in ("%H:%M:%S", "%H:%M"):
try:
return datetime.strptime(str(value), fmt).time()
except ValueError:
continue
raise CommandError(400, "invalid_field_type", f"Invalid time value '{value}'. Expected HH:MM or HH:MM:SS.")
def _parse_int(value: Any) -> int:
try:
return int(str(value))
except ValueError as exc:
raise CommandError(400, "invalid_field_type", f"Invalid integer value '{value}'.") from exc
def _parse_float(value: Any) -> float:
try:
return float(str(value))
except ValueError as exc:
raise CommandError(400, "invalid_field_type", f"Invalid number value '{value}'.") from exc
def _parse_detail(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
try:
parsed = json.loads(str(value))
except json.JSONDecodeError as exc:
raise CommandError(400, "invalid_field_type", "The detail field must be valid JSON.") from exc
if not isinstance(parsed, dict):
raise CommandError(400, "invalid_field_type", "The detail field must be a JSON object.")
return parsed
def _flag_map_for_intent(intent: str) -> dict[str, str]:
if intent == "goal":
return GOAL_FLAG_MAP
if intent == "daylog":
return DAYLOG_FLAG_MAP
if intent == "activity":
return ACTIVITY_FLAG_MAP
raise CommandError(400, "unknown_intent", f"Unknown intent '{intent}'.")
def parse_command_text(command_text: str) -> tuple[str, Optional[str], dict[str, Any]]:
try:
tokens = shlex.split(command_text.strip())
except ValueError as exc:
raise CommandError(400, "invalid_command", "Command text contains unmatched quotes.") from exc
if not tokens:
raise CommandError(400, "invalid_command", "Command text cannot be empty.")
first_token = tokens[0]
if not first_token.startswith("/"):
raise CommandError(400, "invalid_command", "Commands must start with '/'.")
intent = first_token[1:].strip().lower()
if not intent:
raise CommandError(400, "invalid_command", "Command intent is missing.")
if intent == "help":
help_topic = tokens[1].lower() if len(tokens) > 1 else None
if len(tokens) > 2:
raise CommandError(400, "invalid_command", "/help accepts at most one optional topic.")
return intent, help_topic, {}
flag_map = _flag_map_for_intent(intent)
values: dict[str, Any] = {}
index = 1
while index < len(tokens):
token = tokens[index]
if not token.startswith("-"):
raise CommandError(400, "invalid_command", f"Unexpected value '{token}'. Every value must follow a flag.")
flag = token.lstrip("-").lower()
if flag not in flag_map:
raise CommandError(400, "unknown_flag", f"Unknown flag '{token}' for intent '{intent}'.")
index += 1
if index >= len(tokens):
raise CommandError(400, "missing_required_field", f"Missing value for flag '{token}'.")
value = tokens[index]
if value.startswith("-"):
raise CommandError(400, "missing_required_field", f"Missing value for flag '{token}'.")
canonical_name = flag_map[flag]
if canonical_name in values:
raise CommandError(400, "invalid_command", f"Flag '{token}' was provided more than once.")
values[canonical_name] = value
index += 1
return intent, None, values
def build_typed_payload(intent: str, raw_values: dict[str, Any]) -> dict[str, Any]:
if intent == "goal":
payload = dict(raw_values)
return payload
if intent == "daylog":
payload = dict(raw_values)
if "log_date" in payload:
payload["log_date"] = _parse_date(payload["log_date"])
for field_name in ("bed_time", "wake_time", "sleep_time"):
if field_name in payload:
payload[field_name] = _parse_time(payload[field_name])
if "sleep_duration_hours" in payload:
payload["sleep_duration_hours"] = _parse_float(payload["sleep_duration_hours"])
for field_name in ("total_productivity_min", "total_calories"):
if field_name in payload:
payload[field_name] = _parse_int(payload[field_name])
return payload
if intent == "activity":
payload = dict(raw_values)
if "entry_date" in payload:
payload["entry_date"] = _parse_date(payload["entry_date"])
if "occurred_at" in payload:
payload["occurred_at"] = _parse_time(payload["occurred_at"])
if "duration_min" in payload:
payload["duration_min"] = _parse_int(payload["duration_min"])
if "details" in payload:
payload["details"] = _parse_detail(payload["details"])
if "category" in payload:
payload["category"] = str(payload["category"]).lower()
return payload
raise CommandError(400, "unknown_intent", f"Unknown intent '{intent}'.")
def validate_required_fields(intent: str, payload: dict[str, Any]) -> None:
required_fields = {
"goal": ("name",),
"daylog": ("log_date",),
"activity": ("entry_date", "activity_type_name", "category"),
}
missing = [field for field in required_fields[intent] if field not in payload or payload[field] in (None, "")]
if missing:
raise CommandError(400, "missing_required_field", f"Missing required field(s): {', '.join(missing)}.", {"fields": missing})