diff --git a/README.md b/README.md index 9ba2eb9..4479189 100644 --- a/README.md +++ b/README.md @@ -447,6 +447,7 @@ See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for how to contribute to utt. - Stephan Gross <> - Kent Martin <> - fighterpoul <> +- Logan Thomas <> ## License diff --git a/test/integration/Makefile b/test/integration/Makefile index a767da0..00bc080 100644 --- a/test/integration/Makefile +++ b/test/integration/Makefile @@ -28,6 +28,7 @@ all: \ report-details \ report-comments \ report-week-current \ + error-invalid-date \ version $(UTT): @@ -368,6 +369,15 @@ report-truncate-current-activity: $(UTT) @echo "<< REPORT-TRUNCATE-CURRENT-ACTIVITY" +.PHONY: error-invalid-date +error-invalid-date: $(UTT) + @echo + @echo ">> ERROR-INVALID-DATE" + + bash -c 'diff <(utt report not-a-date 2>&1; true) data/utt-error-invalid-date.stderr' + + @echo "<< ERROR-INVALID-DATE" + .PHONY: shell shell: bash diff --git a/test/integration/data/utt-error-invalid-date.stderr b/test/integration/data/utt-error-invalid-date.stderr new file mode 100644 index 0000000..ececf78 --- /dev/null +++ b/test/integration/data/utt-error-invalid-date.stderr @@ -0,0 +1 @@ +error: Invalid date: not-a-date (expected YYYY-MM-DD) diff --git a/test/unit/test_entry.py b/test/unit/test_entry.py index a6a26e9..7788e78 100644 --- a/test/unit/test_entry.py +++ b/test/unit/test_entry.py @@ -55,6 +55,7 @@ ("9:15",), ("2015-1-1 9:15",), ("2014-03-23 An activity",), + ("2025-27-27 17:00 misc: testing",), ] @@ -64,8 +65,6 @@ def test_valid_entries(self): with self.subTest(name=test_case["name"]): entry_parser = EntryParser() entry = entry_parser.parse(test_case["name"]) - if entry is None: - self.fail("EntryParser returned None for valid entry") self.assertEqual(entry.datetime, test_case["expected_datetime"]) self.assertEqual(entry.name, test_case["expected_name"]) @@ -73,9 +72,10 @@ def test_valid_entries(self): class InvalidEntry(unittest.TestCase): - def test_invalid_entries(self): + def test_invalid_entries_raise_value_error(self): + """Test that invalid entries raise ValueError.""" for test_case in INVALID_ENTRIES: with self.subTest(text=test_case[0]): entry_parser = EntryParser() - entry = entry_parser.parse(test_case[0]) - self.assertIsNone(entry) + with self.assertRaises(ValueError): + entry_parser.parse(test_case[0]) diff --git a/test/unit/test_parse_date.py b/test/unit/test_parse_date.py index 7a34a89..2165d26 100644 --- a/test/unit/test_parse_date.py +++ b/test/unit/test_parse_date.py @@ -1,7 +1,8 @@ import datetime import unittest -from utt.components.report_args import parse_date +from utt.components.report_args import parse_absolute_date, parse_absolute_month, parse_date +from utt.exceptions import UttError VALID_ENTRIES = [ ("monday", datetime.date(2015, 2, 11), datetime.date(2015, 2, 9), True), @@ -29,3 +30,19 @@ def test_parse_date(self): with self.subTest(report_date=report_date, today=today, is_past=is_past): actual_report_date = parse_date(today, report_date, is_past) self.assertEqual(actual_report_date, expected_report_date) + + def test_invalid_date_raises_utt_error(self): + with self.assertRaises(UttError): + parse_absolute_date("invalid-date") + + def test_invalid_month_raises_utt_error(self): + with self.assertRaises(UttError): + parse_absolute_month("invalid-month") + + def test_valid_absolute_date(self): + result = parse_absolute_date("2024-01-15") + self.assertEqual(result, datetime.date(2024, 1, 15)) + + def test_valid_absolute_month(self): + result = parse_absolute_month("2024-01") + self.assertEqual(result, datetime.date(2024, 1, 1)) diff --git a/utt/__main__.py b/utt/__main__.py index 24b064b..c4df2e4 100644 --- a/utt/__main__.py +++ b/utt/__main__.py @@ -6,6 +6,7 @@ import utt.plugins from utt.api import _v1 from utt.components.commands import Commands +from utt.exceptions import UttError def iter_namespace(ns_pkg): @@ -34,7 +35,11 @@ def main(): commands: Commands = _v1._private.container[Commands] for command in commands: if command.name == command_name: - _v1._private.container[command.handler_class]() + try: + _v1._private.container[command.handler_class]() + except UttError as e: + print(f"error: {e}", file=sys.stderr) + sys.exit(1) if __name__ == "__main__": diff --git a/utt/components/entries.py b/utt/components/entries.py index 2c4e476..36444fb 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -1,6 +1,7 @@ from typing import Generator, List, Optional, Tuple from ..data_structures.entry import Entry +from ..exceptions import UttError from .entry_lines import EntryLines from .entry_parser import EntryParser @@ -26,12 +27,13 @@ def _parse_line(previous_entry: Optional[Entry], line_number: int, line: str, en if not line: return None - new_entry = entry_parser.parse(line) - if new_entry is None: - raise SyntaxError("Invalid syntax at line %d: %s" % (line_number, line)) + try: + new_entry = entry_parser.parse(line) + except ValueError as e: + raise UttError(f"Invalid entry at line {line_number}: {line}") from e if previous_entry is not None and previous_entry.datetime > new_entry.datetime: - raise Exception("Error line %d. Not in chronological order: %s > %s" % (line_number, previous_entry, new_entry)) + raise UttError(f"Line {line_number} not in chronological order: {line}") previous_entry = new_entry return previous_entry, new_entry diff --git a/utt/components/entry_parser.py b/utt/components/entry_parser.py index 090fd30..0b08ab2 100644 --- a/utt/components/entry_parser.py +++ b/utt/components/entry_parser.py @@ -1,6 +1,5 @@ import datetime import re -from typing import Optional from ..data_structures.entry import Entry @@ -12,16 +11,21 @@ class EntryParser: - def parse(self, string: str) -> Optional[Entry]: + def parse(self, string: str) -> Entry: + """Parse a log line into an Entry. + + Raises: + ValueError: If the line cannot be parsed. + """ match = ENTRY_REGEX.match(string) if match is None: - return None + raise ValueError(f"Invalid syntax: {string}") groupdict = match.groupdict() if "date" not in groupdict or "name" not in groupdict: - return None + raise ValueError(f"Invalid syntax: {string}") date_str = groupdict["date"] date = datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M") diff --git a/utt/components/report_args.py b/utt/components/report_args.py index ac270ab..c454b58 100644 --- a/utt/components/report_args.py +++ b/utt/components/report_args.py @@ -4,6 +4,7 @@ from enum import Enum, auto from typing import NamedTuple, Optional +from ..exceptions import UttError from .now import Now @@ -76,7 +77,10 @@ def parse_date(today: datetime.date, datestring: str, is_past: bool): def parse_absolute_date(datestring): - return datetime.datetime.strptime(datestring, "%Y-%m-%d").date() + try: + return datetime.datetime.strptime(datestring, "%Y-%m-%d").date() + except ValueError as e: + raise UttError(f"Invalid date: {datestring} (expected YYYY-MM-DD)") from e def parse_relative_day(today, datestring): @@ -168,7 +172,10 @@ def parse_integer_month(today, monthstring): def parse_absolute_month(monthstring): - return datetime.datetime.strptime(monthstring, "%Y-%m").date() + try: + return datetime.datetime.strptime(monthstring, "%Y-%m").date() + except ValueError as e: + raise UttError(f"Invalid month: {monthstring} (expected YYYY-MM)") from e def parse_month(today, monthstring): diff --git a/utt/exceptions.py b/utt/exceptions.py new file mode 100644 index 0000000..4831afc --- /dev/null +++ b/utt/exceptions.py @@ -0,0 +1,7 @@ +"""General utt exceptions and warnings.""" + + +class UttError(Exception): + """User-facing error with a friendly message.""" + + pass