From 267ad5b1b54f11f2bc29fba5a24616460216db6a Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Fri, 22 May 2026 10:42:53 +0200 Subject: [PATCH] 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 --- .coverage | Bin 53248 -> 53248 bytes AGENTS.md | 15 +++++--- src/timesheets/cli.py | 39 +++++++++++---------- src/timesheets/utils.py | 67 ++++++++++++++++++++++++++++++++++++ tests/test_utils.py | 74 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 170 insertions(+), 25 deletions(-) diff --git a/.coverage b/.coverage index b76cff1a3599a955c473ac05b7730def79a36333..b167770c44e8537d3eaece7ad2aea83bbc03d831 100644 GIT binary patch delta 81 zcmZozz}&Eac>`O6KnDZ=PyXlp*ZEKKui&4{-?3Rxpom{on4N`@Q-d|_=ogj)e}2B> it^HJMwRG~FelH;bCZLQG(>-AZFmPaC+5EGg-2niFL>fH+ delta 74 zcmZozz}&Eac>`O6KtBWjPyT28*Z5EHFXx}b-@jQ<$23?G%v! diff --git a/AGENTS.md b/AGENTS.md index f803a08..75195f6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,11 +55,16 @@ Do **not** use `pip` or `python` directly. # Print CSV to stdout (date defaults to today) uv run timesheets --input input.md +# Specify a day (positional, accepts YYYY-MM-DD, MM-DD, or DD-MM; - or / separator) +uv run timesheets 2026-05-22 --input input.md +uv run timesheets 05-22 --input input.md + # Write CSV to a file uv run timesheets --input input.md -o output.csv -# Override the date (DD/MM/YY) -uv run timesheets --input input.md --date 22/05/26 +# Override the day (accepts YYYY-MM-DD, MM-DD, or DD-MM; - or / separator) +uv run timesheets --input input.md --day 2026-05-22 +uv run timesheets --input input.md --day 05-22 # Use a specific project map file uv run timesheets --input input.md --map /path/to/project_map.json @@ -73,12 +78,12 @@ cat input.md | uv run timesheets --input - # Fetch today's entries from Joplin (token via env var) JOPLIN_TOKEN=your_token uv run timesheets --joplin -# Fetch entries for a specific date from Joplin -uv run timesheets --joplin --date 22/05/26 --token your_token +# Fetch entries for a specific day from Joplin +uv run timesheets 2026-05-22 --joplin --token your_token ``` The `--joplin` flag and the file `input` argument are mutually exclusive. -When `--joplin` is used, only entries matching the target date (from `--date`, +When `--joplin` is used, only entries matching the target day (positional arg, or today) are returned, filtered by the `# ... YYYY-MM-DD` day heading in the note. The API token can be provided via: diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index 9d7f8b7..3b18259 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -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: diff --git a/src/timesheets/utils.py b/src/timesheets/utils.py index 7ef7d89..4c77753 100644 --- a/src/timesheets/utils.py +++ b/src/timesheets/utils.py @@ -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.""" diff --git a/tests/test_utils.py b/tests/test_utils.py index b043ddf..15c11e2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,9 +3,11 @@ from datetime import date import pytest from timesheets.utils import ( + AmbiguousDateError, decimal_to_hhmm, duration_from_start_end, format_date, + parse_date_arg, parse_duration, strip_markdown_link, ) @@ -98,3 +100,75 @@ class TestFormatDate: def test_single_digit_day_month(self): assert format_date(date(2026, 1, 5)) == "05/01/26" + + +class TestParseDateArg: + # Fix the year used for two-part tests to avoid test brittleness + THIS_YEAR = 2026 + + def _parse(self, value: str) -> date: + # Patch date.today so two-part tests are year-stable + from unittest.mock import patch + + with patch("timesheets.utils.date") as mock_date: + mock_date.today.return_value = date(self.THIS_YEAR, 6, 1) + mock_date.side_effect = lambda *a, **kw: date(*a, **kw) + return parse_date_arg(value) + + # --- YYYY-MM-DD --- + def test_full_dash(self): + assert parse_date_arg("2026-05-22") == date(2026, 5, 22) + + def test_full_slash(self): + assert parse_date_arg("2026/05/22") == date(2026, 5, 22) + + def test_full_invalid_day(self): + with pytest.raises(ValueError): + parse_date_arg("2026-02-30") + + # --- two-part unambiguous: only valid as MM-DD --- + def test_mm_dd_unambiguous(self): + # 01-22: valid as MM=1, DD=22; invalid as MM=22, DD=1 + result = self._parse("01-22") + assert result == date(self.THIS_YEAR, 1, 22) + + # --- two-part unambiguous: only valid as DD-MM --- + def test_dd_mm_unambiguous(self): + # 22-01: invalid as MM=22, DD=1 is actually valid... use 22-13 + # 22-13: valid as DD=22, MM=13 is invalid; DD=13 MM=22 is invalid too + # Use 30-11: valid as DD=30,MM=11; invalid as MM=30,DD=11 + result = self._parse("30-11") + assert result == date(self.THIS_YEAR, 11, 30) + + def test_slash_separator(self): + result = self._parse("01/22") + assert result == date(self.THIS_YEAR, 1, 22) + + # --- ambiguous --- + def test_ambiguous_raises(self): + # 05-06: valid as MM=5,DD=6 and as DD=5,MM=6 + with pytest.raises(AmbiguousDateError, match="ambiguous"): + self._parse("05-06") + + def test_ambiguous_slash_raises(self): + with pytest.raises(AmbiguousDateError): + self._parse("05/06") + + # --- identical interpretations are not ambiguous --- + def test_same_day_month_not_ambiguous(self): + # 05-05: MM=5,DD=5 and DD=5,MM=5 are the same date + result = self._parse("05-05") + assert result == date(self.THIS_YEAR, 5, 5) + + # --- fully invalid --- + def test_invalid_both_parts(self): + # 13-32 is invalid as both MM-DD and DD-MM + with pytest.raises(ValueError): + self._parse("13-32") + + def test_unrecognised_format(self): + with pytest.raises(ValueError, match="Unrecognised"): + parse_date_arg("not-a-date") + + def test_whitespace_stripped(self): + assert parse_date_arg(" 2026-05-22 ") == date(2026, 5, 22)