diff --git a/hytek_parser/_utils.py b/hytek_parser/_utils.py index f4610b5..5060a61 100644 --- a/hytek_parser/_utils.py +++ b/hytek_parser/_utils.py @@ -143,11 +143,17 @@ def int_or_none(value:str|None) -> int|None: return int(value) if value and value.isdigit() else None def date_or_none(value: str|None) -> date|None: - """Safely return either a date from a numeric string value, or None + """Safely return either a valid date from a numeric string value, or None Args: value (str|None): The value to cast to date Returns: - date|None: Either the parsed date value or None""" - return datetime.strptime(value, "%m%d%Y").date() if value and value.isnumeric() else None + date|None: The parsed date, or None for blank, malformed, or invalid values""" + if not value or not value.isnumeric(): + return None + try: + return datetime.strptime(value, "%m%d%Y").date() + except ValueError: + # Some Meet Manager exports contain numeric but impossible dates. + return None diff --git a/hytek_parser/hy3/line_parsers/e_event_parsers.py b/hytek_parser/hy3/line_parsers/e_event_parsers.py index 3a70b93..f5e0805 100644 --- a/hytek_parser/hy3/line_parsers/e_event_parsers.py +++ b/hytek_parser/hy3/line_parsers/e_event_parsers.py @@ -1,7 +1,12 @@ -from datetime import datetime from typing import Any -from hytek_parser._utils import extract, get_age_group, safe_cast, select_from_enum +from hytek_parser._utils import ( + date_or_none, + extract, + get_age_group, + safe_cast, + select_from_enum, +) from hytek_parser.hy3._utils import parse_reaction_time, parse_time, parse_time_or_none from hytek_parser.hy3.enums import ( Course, @@ -121,8 +126,7 @@ def e2_parser( # col 83-87. Signed; sentinels are blank / 0.00 in any sign spelling. reaction_time = parse_reaction_time(extract(line, 83, 5)) - raw_date = extract(line, 88, 8).strip() - date_ = datetime.strptime(raw_date, "%m%d%Y").date() if raw_date else None + date_ = date_or_none(extract(line, 88, 8).strip()) # Get entry event_num, event = file.meet.last_event diff --git a/hytek_parser/hy3/line_parsers/f_relay_parsers.py b/hytek_parser/hy3/line_parsers/f_relay_parsers.py index ac8853c..bab1171 100644 --- a/hytek_parser/hy3/line_parsers/f_relay_parsers.py +++ b/hytek_parser/hy3/line_parsers/f_relay_parsers.py @@ -1,7 +1,12 @@ -from datetime import datetime from typing import Any -from hytek_parser._utils import extract, get_age_group, safe_cast, select_from_enum +from hytek_parser._utils import ( + date_or_none, + extract, + get_age_group, + safe_cast, + select_from_enum, +) from hytek_parser.hy3._utils import parse_reaction_time, parse_time, parse_time_or_none from hytek_parser.hy3.enums import ( Course, @@ -125,8 +130,7 @@ def f2_parser( parse_reaction_time(extract(line, 83 + 5 * i, 5)) for i in range(4) ] - raw_date = extract(line, 103, 8).strip() - date_ = datetime.strptime(raw_date, "%m%d%Y").date() if raw_date else None + date_ = date_or_none(extract(line, 103, 8).strip()) # Get entry event_num, event = file.meet.last_event diff --git a/tests/hy3/line_parsers/test_e_event_parsers.py b/tests/hy3/line_parsers/test_e_event_parsers.py index e0b9b48..dfced6d 100644 --- a/tests/hy3/line_parsers/test_e_event_parsers.py +++ b/tests/hy3/line_parsers/test_e_event_parsers.py @@ -112,10 +112,8 @@ def test_blank_distance_is_zero(self): self.assertEqual(0.0, event.distance) -class TestE2BlankDateColumn(unittest.TestCase): - """Bug 1 — MM2 2.0 (and other MM versions) export E2 lines with a blank - date column. e2_parser must populate timing fields and leave date as None - rather than raising ValueError on the empty strptime.""" +class TestE2ResultDateColumn(unittest.TestCase): + """Blank or invalid E2 dates must not discard otherwise valid results.""" def _build_file_with_event(self): opts = {"default_country": "USA"} @@ -140,6 +138,14 @@ def test_e2_parser_with_blank_date_does_not_raise(self): self.assertEqual(54.79, entry.finals_time) self.assertIsNone(entry.finals_date) + def test_e2_parser_with_invalid_date_does_not_raise(self): + file, opts = self._build_file_with_event() + e2_line = "E2F 54.79Y 0 2 3 4 12 0 0.00 54.93 0.00 54.79 0.00 99999999 0 25" + result = e2_parser(e2_line, file, opts) + entry = result.meet.events["22X"].last_entry + self.assertEqual(54.79, entry.finals_time) + self.assertIsNone(entry.finals_date) + class TestE1MeetDivision(unittest.TestCase): """capture Meet Division at cols 77-79 (e.g. 'VR', 'JV', 'A'/'AA'/numeric).""" diff --git a/tests/hy3/line_parsers/test_f_relay_parsers.py b/tests/hy3/line_parsers/test_f_relay_parsers.py index b50834c..1ebb75d 100644 --- a/tests/hy3/line_parsers/test_f_relay_parsers.py +++ b/tests/hy3/line_parsers/test_f_relay_parsers.py @@ -8,9 +8,8 @@ from hytek_parser.hy3.line_parsers.f_relay_parsers import f1_parser, f2_parser, f3_parser -class TestF2BlankDateColumn(unittest.TestCase): - """Bug 1 — F2 lines with blank date column must populate timing fields - and leave date None instead of raising.""" +class TestF2ResultDateColumn(unittest.TestCase): + """Blank or invalid F2 dates must not discard otherwise valid results.""" def _build_file_with_relay_entry(self): opts = {"default_country": "USA"} @@ -37,6 +36,14 @@ def test_f2_parser_with_blank_date_does_not_raise(self): self.assertEqual(111.06, entry.finals_time) self.assertIsNone(entry.finals_date) + def test_f2_parser_with_invalid_date_does_not_raise(self): + file, opts = self._build_file_with_relay_entry() + f2_line = "F2F 111.06Y 0 2 6 4 4 0 111.00 111.16 0.00 111.06 0.00 99999999 46" + result = f2_parser(f2_line, file, opts) + entry = result.meet.last_event[1].last_entry + self.assertEqual(111.06, entry.finals_time) + self.assertIsNone(entry.finals_date) + class TestF1PerEntryAttribution(unittest.TestCase): """Bug 2-A — relay_team_id and relay_swim_team_code must live on diff --git a/tests/hy3/test_utils.py b/tests/hy3/test_utils.py index 7756e0a..7272600 100644 --- a/tests/hy3/test_utils.py +++ b/tests/hy3/test_utils.py @@ -28,6 +28,9 @@ def test_select_from_enum(self) -> None: def test_date_or_none(self) -> None: self.assertIsNone(date_or_none("")) + self.assertIsNone(date_or_none("notadate")) + self.assertIsNone(date_or_none("99999999")) + self.assertIsNone(date_or_none("02312025")) self.assertEqual(datetime.date(1970, 1, 2), date_or_none("01021970")) class TestParseReactionTime(unittest.TestCase):