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:
parent
715e0988dc
commit
267ad5b1b5
5 changed files with 170 additions and 25 deletions
BIN
.coverage
BIN
.coverage
Binary file not shown.
15
AGENTS.md
15
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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue