feat(cli): add flexible positional day argument

- Add parse_date_arg() to utils.py supporting YYYY-MM-DD, MM-DD, and
  DD-MM formats with either - or / as separator
- Add AmbiguousDateError for two-part dates valid as both MM-DD and DD-MM
- Replace --day flag with a positional optional argument (defaults to today)
- Remove old _parse_date() helper from cli.py
This commit is contained in:
Jef Roosens 2026-05-22 10:42:53 +02:00
parent 715e0988dc
commit 267ad5b1b5
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
5 changed files with 170 additions and 25 deletions

View file

@ -6,7 +6,7 @@ from datetime import date
from .output import print_summary, write_csv
from .parser import aggregate_rows, filter_rows_by_date, parse_document
from .projects import load_project_map
from .utils import format_date
from .utils import AmbiguousDateError, format_date, parse_date_arg
def build_parser() -> argparse.ArgumentParser:
@ -44,8 +44,12 @@ def build_parser() -> argparse.ArgumentParser:
default=None,
)
parser.add_argument(
"--date",
help="Date to use in the output (DD/MM/YY). Defaults to today.",
"day",
nargs="?",
help=(
"Day to extract timesheets for. Accepts YYYY-MM-DD, MM-DD, or DD-MM "
"(- or / as separator). Defaults to today."
),
default=None,
)
parser.add_argument(
@ -76,26 +80,21 @@ def _resolve_token(args: argparse.Namespace) -> str:
return token
def _parse_date(date_str: str | None) -> date:
"""Parse DD/MM/YY date string, or return today."""
if date_str is None:
return date.today()
try:
from datetime import datetime
return datetime.strptime(date_str, "%d/%m/%y").date()
except ValueError:
print(
f"Error: invalid date format {date_str!r}, expected DD/MM/YY.",
file=sys.stderr,
)
sys.exit(1)
def main() -> None:
args = build_parser().parse_args()
target_date = _parse_date(args.date)
if args.day is None:
target_date = date.today()
else:
try:
target_date = parse_date_arg(args.day)
except AmbiguousDateError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
except ValueError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
date_str = format_date(target_date)
if args.joplin:

View file

@ -1,6 +1,73 @@
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
raise ValueError(
f"Unrecognised date format: {value!r}. "
"Expected YYYY-MM-DD, MM-DD, or DD-MM (- or / as separator)."
)
def parse_duration(duration_str: str) -> float:
"""Convert HH:MM duration string to decimal hours."""