import datetime
import re
from typing import Optional, Tuple
def parse_flexible_month(value: str) -> str:
"""
Convert flexible month input into 'YYYY-MM'.
Acceptable inputs:
- '2025-01'
- 'Jan 2025', 'January 2025'
- 'Jan', 'January' (assumes current year)
- 'next month'
- 'last month'
- '' or None → current month
Raises ValueError if parsing fails.
"""
if not value or not value.strip():
return datetime.date.today().strftime("%Y-%m")
text = value.strip().lower()
# Handle "next month"
if text == "next month":
today = datetime.date.today()
year = today.year + (1 if today.month == 12 else 0)
month = 1 if today.month == 12 else today.month + 1
return f"{year:04d}-{month:02d}"
# Handle "last month"
if text == "last month":
today = datetime.date.today()
year = today.year - (1 if today.month == 1 else 0)
month = 12 if today.month == 1 else today.month - 1
return f"{year:04d}-{month:02d}"
# YYYY-MM direct pattern
if re.match(r"^\d{4}-\d{2}$", text):
return text
# Try parsing natural month names with year (e.g., "Jan 2025", "March 2024")
try:
dt = datetime.datetime.strptime(text, "%b %Y")
return dt.strftime("%Y-%m")
except Exception:
pass
try:
dt = datetime.datetime.strptime(text, "%B %Y")
return dt.strftime("%Y-%m")
except Exception:
pass
# Try month name without year → assume current year
try:
dt = datetime.datetime.strptime(text, "%b")
return f"{datetime.date.today().year}-{dt.month:02d}"
except Exception:
pass
try:
dt = datetime.datetime.strptime(text, "%B")
return f"{datetime.date.today().year}-{dt.month:02d}"
except Exception:
pass
raise ValueError(f"Could not parse month value: '{value}'")
def get_month_range(month: Optional[str] = None) -> Tuple[str, str]:
"""
Return (start_date_iso, end_date_iso) for the given month.
- month: optional string like "2025-12" or "Dec 2025" or ''/None -> current month
Result: ("YYYY-MM-01", "YYYY-MM-DD") where the second is the last day of the month.
"""
# Prefer parse_month if available (returns "YYYY-MM")
try:
from .date_utils import parse_month # safe: local ref if file already provides it
parsed_month = parse_month(month) if month else None
except Exception:
parsed_month = None
if not parsed_month:
# Try to interpret as YYYY-MM directly, else use today
if month:
# normalize possible inputs like "2025-12"
try:
maybe = month.strip()
if len(maybe) == 7 and maybe[4] == '-':
parsed_month = maybe
else:
# fallback: attempt to parse via dateparser if installed
import dateparser # this may exist in your environment
dt = dateparser.parse("1 " + month)
if dt:
parsed_month = dt.strftime("%Y-%m")
except Exception:
parsed_month = None
if not parsed_month:
today = datetime.date.today()
year = today.year
month_num = today.month
else:
year, month_num = map(int, parsed_month.split("-"))
# first day
start = datetime.date(year, month_num, 1)
# compute last day: first of next month minus one day
if month_num == 12:
next_month = datetime.date(year + 1, 1, 1)
else:
next_month = datetime.date(year, month_num + 1, 1)
last = next_month - datetime.timedelta(days=1)
return start.isoformat(), last.isoformat()
def parse_date(value: str) -> str:
"""
Parse a flexible date string into ISO format YYYY-MM-DD.
Accepts:
- '2025-01-31'
- '31 Jan 2025'
- 'Jan 31, 2025'
- 'today'
- 'yesterday'
- 'tomorrow'
Falls back to today's date if parsing fails.
"""
if not value or not value.strip():
return datetime.date.today().isoformat()
text = value.strip().lower()
# quick keywords
if text == "today":
return datetime.date.today().isoformat()
if text == "yesterday":
return (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
if text == "tomorrow":
return (datetime.date.today() + datetime.timedelta(days=1)).isoformat()
# ISO direct
try:
return datetime.datetime.strptime(text, "%Y-%m-%d").date().isoformat()
except Exception:
pass
# Day Month Year formats
formats = [
"%d %b %Y", "%d %B %Y",
"%b %d %Y", "%B %d %Y",
"%d-%b-%Y", "%d-%B-%Y",
"%d/%m/%Y", "%Y/%m/%d",
"%m/%d/%Y",
]
for fmt in formats:
try:
return datetime.datetime.strptime(text, fmt).date().isoformat()
except Exception:
continue
# As absolute fallback: today
return datetime.date.today().isoformat()
def parse_month(value: str) -> str:
"""
Normalize a month string into 'YYYY-MM'.
Accepts:
- '2025-01'
- 'Jan 2025'
- 'January 2025'
- 'Jan'
- 'January'
- '' or None -> current month
Raises ValueError for invalid formats.
"""
if not value or not value.strip():
# blank means current month
return datetime.date.today().strftime("%Y-%m")
text = value.strip().lower()
# ISO direct match
if re.match(r"^\d{4}-\d{2}$", text):
return text
# Try explicit month + year
try:
dt = datetime.datetime.strptime(text, "%b %Y")
return dt.strftime("%Y-%m")
except Exception:
pass
try:
dt = datetime.datetime.strptime(text, "%B %Y")
return dt.strftime("%Y-%m")
except Exception:
pass
# Month name only -> assume current year
try:
dt = datetime.datetime.strptime(text, "%b")
year = datetime.date.today().year
return f"{year}-{dt.month:02d}"
except Exception:
pass
try:
dt = datetime.datetime.strptime(text, "%B")
year = datetime.date.today().year
return f"{year}-{dt.month:02d}"
except Exception:
pass
raise ValueError(f"Could not parse month string '{value}'")
def days_remaining(reference_date: datetime.date | None = None) -> int:
"""
Return number of days remaining in the month for `reference_date`.
- If reference_date is None, uses today().
- Returns 0 when it's the last day of the month.
"""
if reference_date is None:
reference_date = datetime.date.today()
year = reference_date.year
month = reference_date.month
# First day of next month
if month == 12:
next_month = datetime.date(year + 1, 1, 1)
else:
next_month = datetime.date(year, month + 1, 1)
last_day = next_month - datetime.timedelta(days=1)
remaining = (last_day - reference_date).days
return max(0, remaining)
def is_valid_date(value: str) -> bool:
"""
Returns True if the string is a valid date in any of the formats
supported by parse_date(). Does NOT raise exceptions.
"""
try:
_ = parse_date(value)
return True
except Exception:
return False
def get_today() -> str:
"""Return today's date as YYYY-MM-DD."""
return datetime.date.today().isoformat()
def parse_flexible_date(value: str) -> str:
"""
Backwards-compatible alias for flexible date parsing.
Uses parse_date() internally.
"""
return parse_date(value)
def get_days_remaining_in_month(date: str | None = None) -> int:
"""
Return number of days remaining in the month.
Accepts:
- None → uses today
- 'YYYY-MM-DD' → parses date
- any flexible date string supported by parse_date()
"""
if date:
try:
dt = parse_date(date)
year, month, day = map(int, dt.split("-"))
ref = datetime.date(year, month, day)
except Exception:
ref = datetime.date.today()
else:
ref = datetime.date.today()
return days_remaining(ref)