diff --git a/serializable/helpers.py b/serializable/helpers.py index 92c03a2..f393ffd 100644 --- a/serializable/helpers.py +++ b/serializable/helpers.py @@ -19,7 +19,7 @@ from datetime import date, datetime from logging import getLogger -from re import sub as re_sub +from re import compile as re_compile from typing import TYPE_CHECKING, Any, Optional, Type, TypeVar, Union if TYPE_CHECKING: # pragma: no cover @@ -195,6 +195,23 @@ def serialize(cls, o: Any) -> str: raise ValueError(f'Attempt to serialize a non-date: {o.__class__}') + # region fixup_microseconds + # see https://github.com/madpah/serializable/pull/138 + + __PATTERN_FRACTION = re_compile(r'\.\d+') + + @classmethod + def __fix_microseconds(cls, v: str) -> str: + """ + Fix for Python's violation of ISO8601 for :py:meth:`datetime.fromisoformat`. + 1. Ensure either 0 or exactly 6 decimal places for seconds. + Background: py<3.11 supports either 6 or 0 digits for milliseconds when parsing. + 2. Ensure correct rounding of microseconds on the 6th digit. + """ + return cls.__PATTERN_FRACTION.sub(lambda m: f'{(float(m.group(0))):.6f}'[1:], v) + + # endregion fixup_microseconds + @classmethod def deserialize(cls, o: Any) -> datetime: try: @@ -202,14 +219,10 @@ def deserialize(cls, o: Any) -> datetime: if v.startswith('-'): # Remove any leading hyphen v = v[1:] - - # Ensure any milliseconds are 6 digits - # Background: py<3.11 supports six or less digits for milliseconds - v = re_sub(r'\.(\d{1,6})', lambda m: f'.{int(m.group()[1:]):06}', v) - if v.endswith('Z'): # Replace ZULU time with 00:00 offset v = f'{v[:-1]}+00:00' - return datetime.fromisoformat(v) + return datetime.fromisoformat( + cls.__fix_microseconds(v)) except ValueError: raise ValueError(f'Date-Time string supplied ({o}) is not a supported ISO Format') diff --git a/tests/test_helpers.py b/tests/test_helpers.py index b9ea16b..24c5f3c 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -145,9 +145,35 @@ def test_deserialize_valid_5(self) -> None: ) def test_deserialize_valid_6(self) -> None: + """Test that less than 6 decimal places in the seconds field is parsed correctly.""" self.assertEqual( XsdDateTime.deserialize('2001-10-26T21:32:52.12679'), - datetime(year=2001, month=10, day=26, hour=21, minute=32, second=52, microsecond=12679, tzinfo=None) + datetime(year=2001, month=10, day=26, hour=21, minute=32, second=52, microsecond=126790, tzinfo=None) + ) + + def test_deserialize_valid_7(self) -> None: + """Test that exactly 6 decimal places in the seconds field is parsed correctly.""" + self.assertEqual( + XsdDateTime.deserialize('2024-09-23T08:06:09.185596Z'), + datetime(year=2024, month=9, day=23, hour=8, minute=6, + second=9, microsecond=185596, tzinfo=timezone.utc) + ) + + def test_deserialize_valid_8(self) -> None: + """Test that more than 6 decimal places in the seconds field is parsed correctly.""" + self.assertEqual( + # values are chosen to showcase rounding on microseconds + XsdDateTime.deserialize('2024-09-23T08:06:09.185596536Z'), + datetime(year=2024, month=9, day=23, hour=8, minute=6, + second=9, microsecond=185597, tzinfo=timezone.utc) + ) + + def test_deserialize_valid_9(self) -> None: + """Test that a lot more than 6 decimal places in the seconds is parsed correctly.""" + self.assertEqual( + # values are chosen to showcase rounding on microseconds + XsdDateTime.deserialize('2024-09-23T08:06:09.18559653666666666666666666666666'), + datetime(year=2024, month=9, day=23, hour=8, minute=6, second=9, microsecond=185597, tzinfo=None) ) def test_serialize_1(self) -> None: