From a79b8297e5d7966da990236a9616e9ab5c477e78 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Fri, 28 Nov 2025 11:22:32 -0600 Subject: [PATCH 01/10] inital commit --- test/unit/test_entry.py | 6 ++++++ test/unit/test_parse_date.py | 19 ++++++++++++++++++- utt/__main__.py | 7 ++++++- utt/components/entries.py | 14 ++++++++++++-- utt/components/report_args.py | 11 +++++++++-- 5 files changed, 51 insertions(+), 6 deletions(-) diff --git a/test/unit/test_entry.py b/test/unit/test_entry.py index a6a26e9..1e95149 100644 --- a/test/unit/test_entry.py +++ b/test/unit/test_entry.py @@ -79,3 +79,9 @@ def test_invalid_entries(self): entry_parser = EntryParser() entry = entry_parser.parse(test_case[0]) self.assertIsNone(entry) + + def test_invalid_date_raises_value_error(self): + """Test that entries with invalid dates raise ValueError.""" + entry_parser = EntryParser() + with self.assertRaises(ValueError): + entry_parser.parse("2025-27-27 17:00 misc: testing") diff --git a/test/unit/test_parse_date.py b/test/unit/test_parse_date.py index 7a34a89..eab8253 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.entries import UttError +from utt.components.report_args import parse_absolute_date, parse_absolute_month, parse_date 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..5fe1774 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.components.entries 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("error: %s" % e, file=sys.stderr) + sys.exit(1) if __name__ == "__main__": diff --git a/utt/components/entries.py b/utt/components/entries.py index 2c4e476..ebc06f9 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -7,6 +7,12 @@ Entries = List[Entry] +class UttError(Exception): + """User-facing error with a friendly message.""" + + pass + + def entries(entry_lines: EntryLines, entry_parser: EntryParser) -> Entries: return list(_parse_log(entry_lines(), entry_parser)) @@ -26,12 +32,16 @@ def _parse_line(previous_entry: Optional[Entry], line_number: int, line: str, en if not line: return None - new_entry = entry_parser.parse(line) + try: + new_entry = entry_parser.parse(line) + except ValueError: + raise UttError("Invalid date at line %d: %s" % (line_number, line)) + if new_entry is None: raise SyntaxError("Invalid syntax at line %d: %s" % (line_number, line)) 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("Line %d not in chronological order: %s" % (line_number, line)) previous_entry = new_entry return previous_entry, new_entry diff --git a/utt/components/report_args.py b/utt/components/report_args.py index ac270ab..2075b08 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 .entries 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: + raise UttError("Invalid date: %s (expected YYYY-MM-DD)" % datestring) 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: + raise UttError("Invalid month: %s (expected YYYY-MM)" % monthstring) def parse_month(today, monthstring): From d78e737381bb528177d6b25e76cbd9e4135a4aa8 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Tue, 16 Dec 2025 08:02:16 -0600 Subject: [PATCH 02/10] refactor: move UttError to its own module UttError is independent of entries.py and can be used in other contexts, so it belongs in its own file for better reusability. --- test/unit/test_parse_date.py | 2 +- utt/__main__.py | 2 +- utt/components/entries.py | 7 +------ utt/components/report_args.py | 2 +- utt/components/utt_error.py | 4 ++++ 5 files changed, 8 insertions(+), 9 deletions(-) create mode 100644 utt/components/utt_error.py diff --git a/test/unit/test_parse_date.py b/test/unit/test_parse_date.py index eab8253..3206e27 100644 --- a/test/unit/test_parse_date.py +++ b/test/unit/test_parse_date.py @@ -1,7 +1,7 @@ import datetime import unittest -from utt.components.entries import UttError +from utt.components.utt_error import UttError from utt.components.report_args import parse_absolute_date, parse_absolute_month, parse_date VALID_ENTRIES = [ diff --git a/utt/__main__.py b/utt/__main__.py index 5fe1774..53afa0b 100644 --- a/utt/__main__.py +++ b/utt/__main__.py @@ -6,7 +6,7 @@ import utt.plugins from utt.api import _v1 from utt.components.commands import Commands -from utt.components.entries import UttError +from utt.components.utt_error import UttError def iter_namespace(ns_pkg): diff --git a/utt/components/entries.py b/utt/components/entries.py index ebc06f9..2d8c285 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -3,16 +3,11 @@ from ..data_structures.entry import Entry from .entry_lines import EntryLines from .entry_parser import EntryParser +from .utt_error import UttError Entries = List[Entry] -class UttError(Exception): - """User-facing error with a friendly message.""" - - pass - - def entries(entry_lines: EntryLines, entry_parser: EntryParser) -> Entries: return list(_parse_log(entry_lines(), entry_parser)) diff --git a/utt/components/report_args.py b/utt/components/report_args.py index 2075b08..689faac 100644 --- a/utt/components/report_args.py +++ b/utt/components/report_args.py @@ -4,7 +4,7 @@ from enum import Enum, auto from typing import NamedTuple, Optional -from .entries import UttError +from .utt_error import UttError from .now import Now diff --git a/utt/components/utt_error.py b/utt/components/utt_error.py new file mode 100644 index 0000000..634e445 --- /dev/null +++ b/utt/components/utt_error.py @@ -0,0 +1,4 @@ +class UttError(Exception): + """User-facing error with a friendly message.""" + + pass From 8c496a10f494d84c012632ff55cc7d378c35e17f Mon Sep 17 00:00:00 2001 From: loganthomas Date: Tue, 16 Dec 2025 08:02:39 -0600 Subject: [PATCH 03/10] style: use f-strings for all error message formatting Replace legacy %s/%d formatting with modern f-strings for better readability and consistency. --- utt/__main__.py | 2 +- utt/components/entries.py | 6 +++--- utt/components/report_args.py | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/utt/__main__.py b/utt/__main__.py index 53afa0b..abba164 100644 --- a/utt/__main__.py +++ b/utt/__main__.py @@ -38,7 +38,7 @@ def main(): try: _v1._private.container[command.handler_class]() except UttError as e: - print("error: %s" % e, file=sys.stderr) + print(f"error: {e}", file=sys.stderr) sys.exit(1) diff --git a/utt/components/entries.py b/utt/components/entries.py index 2d8c285..fe3fb24 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -30,13 +30,13 @@ def _parse_line(previous_entry: Optional[Entry], line_number: int, line: str, en try: new_entry = entry_parser.parse(line) except ValueError: - raise UttError("Invalid date at line %d: %s" % (line_number, line)) + raise UttError(f"Invalid date at line {line_number}: {line}") if new_entry is None: - raise SyntaxError("Invalid syntax at line %d: %s" % (line_number, line)) + raise SyntaxError(f"Invalid syntax at line {line_number}: {line}") if previous_entry is not None and previous_entry.datetime > new_entry.datetime: - raise UttError("Line %d not in chronological order: %s" % (line_number, line)) + 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/report_args.py b/utt/components/report_args.py index 689faac..681e598 100644 --- a/utt/components/report_args.py +++ b/utt/components/report_args.py @@ -80,7 +80,7 @@ def parse_absolute_date(datestring): try: return datetime.datetime.strptime(datestring, "%Y-%m-%d").date() except ValueError: - raise UttError("Invalid date: %s (expected YYYY-MM-DD)" % datestring) + raise UttError(f"Invalid date: {datestring} (expected YYYY-MM-DD)") def parse_relative_day(today, datestring): @@ -175,7 +175,7 @@ def parse_absolute_month(monthstring): try: return datetime.datetime.strptime(monthstring, "%Y-%m").date() except ValueError: - raise UttError("Invalid month: %s (expected YYYY-MM)" % monthstring) + raise UttError(f"Invalid month: {monthstring} (expected YYYY-MM)") def parse_month(today, monthstring): From db4d1b00ed6865054b14f0b021db45f6b7c59f1a Mon Sep 17 00:00:00 2001 From: loganthomas Date: Tue, 16 Dec 2025 08:03:07 -0600 Subject: [PATCH 04/10] test: add integration test for UttError clean message output Verify that UttError displays a clean error message without a stack trace when an invalid date is provided to the report command. --- test/integration/Makefile | 10 ++++++++++ test/integration/data/utt-error-invalid-date.stderr | 1 + 2 files changed, 11 insertions(+) create mode 100644 test/integration/data/utt-error-invalid-date.stderr 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) From 84c70a0f0055791ad5422068d71214ad32a0c33f Mon Sep 17 00:00:00 2001 From: loganthomas Date: Tue, 16 Dec 2025 08:03:23 -0600 Subject: [PATCH 05/10] docs: add Logan Thomas to contributors --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 50e2703..2006873 100644 --- a/README.md +++ b/README.md @@ -442,6 +442,7 @@ See [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md) for how to contribute to utt. - Stephan Gross <> - Kent Martin <> - fighterpoul <> +- Logan Thomas <> ## License From 7c420e3a771fd4db87183c7cbe1421b26e9f2be5 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Tue, 16 Dec 2025 08:10:50 -0600 Subject: [PATCH 06/10] refactor: move exceptions to package root Move UttError from utt/components/utt_error.py to utt/exceptions.py following Python conventions (requests, boto3, etc.) for placing exceptions at the package root. --- test/unit/test_parse_date.py | 2 +- utt/__main__.py | 2 +- utt/components/entries.py | 2 +- utt/components/report_args.py | 2 +- utt/{components/utt_error.py => exceptions.py} | 3 +++ 5 files changed, 7 insertions(+), 4 deletions(-) rename utt/{components/utt_error.py => exceptions.py} (66%) diff --git a/test/unit/test_parse_date.py b/test/unit/test_parse_date.py index 3206e27..e86ac71 100644 --- a/test/unit/test_parse_date.py +++ b/test/unit/test_parse_date.py @@ -1,7 +1,7 @@ import datetime import unittest -from utt.components.utt_error import UttError +from utt.exceptions import UttError from utt.components.report_args import parse_absolute_date, parse_absolute_month, parse_date VALID_ENTRIES = [ diff --git a/utt/__main__.py b/utt/__main__.py index abba164..c4df2e4 100644 --- a/utt/__main__.py +++ b/utt/__main__.py @@ -6,7 +6,7 @@ import utt.plugins from utt.api import _v1 from utt.components.commands import Commands -from utt.components.utt_error import UttError +from utt.exceptions import UttError def iter_namespace(ns_pkg): diff --git a/utt/components/entries.py b/utt/components/entries.py index fe3fb24..14651ee 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -3,7 +3,7 @@ from ..data_structures.entry import Entry from .entry_lines import EntryLines from .entry_parser import EntryParser -from .utt_error import UttError +from ..exceptions import UttError Entries = List[Entry] diff --git a/utt/components/report_args.py b/utt/components/report_args.py index 681e598..0a43dc9 100644 --- a/utt/components/report_args.py +++ b/utt/components/report_args.py @@ -4,7 +4,7 @@ from enum import Enum, auto from typing import NamedTuple, Optional -from .utt_error import UttError +from ..exceptions import UttError from .now import Now diff --git a/utt/components/utt_error.py b/utt/exceptions.py similarity index 66% rename from utt/components/utt_error.py rename to utt/exceptions.py index 634e445..4831afc 100644 --- a/utt/components/utt_error.py +++ b/utt/exceptions.py @@ -1,3 +1,6 @@ +"""General utt exceptions and warnings.""" + + class UttError(Exception): """User-facing error with a friendly message.""" From 85a2cd5ba201ead30d76c339fc5373c9af70b4e7 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Tue, 16 Dec 2025 08:14:37 -0600 Subject: [PATCH 07/10] refactor: make EntryParser.parse raise ValueError instead of returning None Simplifies error handling by having a single path for parsing exceptions. EntryParser.parse now returns Entry (not Optional[Entry]) and raises ValueError for any unparseable input. --- test/unit/test_entry.py | 9 ++++----- utt/components/entries.py | 5 +---- utt/components/entry_parser.py | 12 ++++++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/test/unit/test_entry.py b/test/unit/test_entry.py index 1e95149..f62fcf9 100644 --- a/test/unit/test_entry.py +++ b/test/unit/test_entry.py @@ -64,8 +64,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,12 +71,13 @@ 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]) def test_invalid_date_raises_value_error(self): """Test that entries with invalid dates raise ValueError.""" diff --git a/utt/components/entries.py b/utt/components/entries.py index 14651ee..6cdae2d 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -30,10 +30,7 @@ def _parse_line(previous_entry: Optional[Entry], line_number: int, line: str, en try: new_entry = entry_parser.parse(line) except ValueError: - raise UttError(f"Invalid date at line {line_number}: {line}") - - if new_entry is None: - raise SyntaxError(f"Invalid syntax at line {line_number}: {line}") + raise UttError(f"Invalid entry at line {line_number}: {line}") if previous_entry is not None and previous_entry.datetime > new_entry.datetime: raise UttError(f"Line {line_number} not in chronological order: {line}") 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") From 824b2d12878588c15c41ef8f269a04b55c5d1bde Mon Sep 17 00:00:00 2001 From: loganthomas Date: Mon, 22 Dec 2025 12:01:57 -0600 Subject: [PATCH 08/10] test(entry): consolidate invalid date test case into parameterized entries --- test/unit/test_entry.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/test/unit/test_entry.py b/test/unit/test_entry.py index f62fcf9..20a1642 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",), ] @@ -79,8 +80,3 @@ def test_invalid_entries_raise_value_error(self): with self.assertRaises(ValueError): entry_parser.parse(test_case[0]) - def test_invalid_date_raises_value_error(self): - """Test that entries with invalid dates raise ValueError.""" - entry_parser = EntryParser() - with self.assertRaises(ValueError): - entry_parser.parse("2025-27-27 17:00 misc: testing") From 6b43893dabfdf16fe9f43844c98758b6bf3e8dd7 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Mon, 22 Dec 2025 12:07:08 -0600 Subject: [PATCH 09/10] refactor: chain exceptions with from e when re-raising UttError --- utt/components/entries.py | 4 ++-- utt/components/report_args.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/utt/components/entries.py b/utt/components/entries.py index 6cdae2d..7a45193 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -29,8 +29,8 @@ def _parse_line(previous_entry: Optional[Entry], line_number: int, line: str, en try: new_entry = entry_parser.parse(line) - except ValueError: - raise UttError(f"Invalid entry at line {line_number}: {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 UttError(f"Line {line_number} not in chronological order: {line}") diff --git a/utt/components/report_args.py b/utt/components/report_args.py index 0a43dc9..c454b58 100644 --- a/utt/components/report_args.py +++ b/utt/components/report_args.py @@ -79,8 +79,8 @@ def parse_date(today: datetime.date, datestring: str, is_past: bool): def parse_absolute_date(datestring): try: return datetime.datetime.strptime(datestring, "%Y-%m-%d").date() - except ValueError: - raise UttError(f"Invalid date: {datestring} (expected YYYY-MM-DD)") + except ValueError as e: + raise UttError(f"Invalid date: {datestring} (expected YYYY-MM-DD)") from e def parse_relative_day(today, datestring): @@ -174,8 +174,8 @@ def parse_integer_month(today, monthstring): def parse_absolute_month(monthstring): try: return datetime.datetime.strptime(monthstring, "%Y-%m").date() - except ValueError: - raise UttError(f"Invalid month: {monthstring} (expected YYYY-MM)") + except ValueError as e: + raise UttError(f"Invalid month: {monthstring} (expected YYYY-MM)") from e def parse_month(today, monthstring): From 53bb079966e0844a9bdecd21094c708360599baa Mon Sep 17 00:00:00 2001 From: loganthomas Date: Mon, 22 Dec 2025 14:11:09 -0600 Subject: [PATCH 10/10] style: remove trailing blank lines and sort imports --- test/unit/test_entry.py | 1 - test/unit/test_parse_date.py | 2 +- utt/components/entries.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/test/unit/test_entry.py b/test/unit/test_entry.py index 20a1642..7788e78 100644 --- a/test/unit/test_entry.py +++ b/test/unit/test_entry.py @@ -79,4 +79,3 @@ def test_invalid_entries_raise_value_error(self): entry_parser = EntryParser() 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 e86ac71..2165d26 100644 --- a/test/unit/test_parse_date.py +++ b/test/unit/test_parse_date.py @@ -1,8 +1,8 @@ import datetime import unittest -from utt.exceptions import UttError 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), diff --git a/utt/components/entries.py b/utt/components/entries.py index 7a45193..36444fb 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -1,9 +1,9 @@ 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 -from ..exceptions import UttError Entries = List[Entry]