From 45f22e365a7c53ee5e7967384fa2b10a69c1d69a Mon Sep 17 00:00:00 2001 From: I335851 Date: Sat, 20 Jun 2026 13:27:25 +0200 Subject: [PATCH] refactor: replace regex datetime parsing with fromisoformat, closes #191 - Replace the regexp-based ISO datetime parsing introduced in #184 and the strptime fallback chain in parse_datetime_literal with datetime.fromisoformat. - Fixes the non-padded test fixture '2038-01-19T3:14:7' to valid ISO 8601. - Improve test coverage for sub-6-digit fractional seconds, no-seconds-with-offset, microseconds with Z suffix, and None inputs for both Edm.DateTime and Edm.DateTimeOffset after considered the Odata V2 specification for those types. --- CHANGELOG.md | 1 + pyodata/v2/model.py | 38 +++++++------------ tests/test_model_v2.py | 16 ++++++++ .../test_model_v2_EdmStructTypeSerializer.py | 2 +- 4 files changed, 32 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f952c045..90314012 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] - service: let FunctionRequests return a list of EntityProxies instead of the raw json, when the `ReturnType` is a Collection. - Emil B. +- model: replace regexp-based ISO datetime parsing with `datetime.fromisoformat` for `Edm.DateTime` and `Edm.DateTimeOffset` - Petr Hanak ## [1.11.2] diff --git a/pyodata/v2/model.py b/pyodata/v2/model.py index be47da61..9a8e60b5 100644 --- a/pyodata/v2/model.py +++ b/pyodata/v2/model.py @@ -396,15 +396,14 @@ def ms_since_epoch_to_datetime(value, tzinfo): def parse_datetime_literal(value): try: - return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f') + # fromisoformat on Python < 3.11 requires fractional seconds to be exactly + # 3 or 6 digits; pad to 6 if needed + if '.' in value: + dt_part, frac = value.rsplit('.', 1) + value = f'{dt_part}.{frac.ljust(6, "0")}' + return datetime.datetime.fromisoformat(value) except ValueError: - try: - return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S') - except ValueError: - try: - return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M') - except ValueError: - raise PyODataModelError(f'Cannot decode datetime from value {value}.') + raise PyODataModelError(f'Cannot decode datetime from value {value}.') class EdmDateTimeTypTraits(EdmPrefixedTypTraits): @@ -487,7 +486,6 @@ def from_literal(self, value): value = super(EdmDateTimeTypTraits, self).from_literal(value) - # Note: parse_datetime_literal raises a PyODataModelError exception on invalid formats return parse_datetime_literal(value).replace(tzinfo=datetime.timezone.utc) @@ -557,21 +555,13 @@ def from_literal(self, value): value = super(EdmDateTimeOffsetTypTraits, self).from_literal(value) try: - # Note: parse_datetime_literal raises a PyODataModelError exception on invalid formats - if re.match(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z', value, flags=re.ASCII | re.IGNORECASE): - datetime_part = value[:-1] - tz_info = datetime.timezone.utc - else: - match = re.match(r'(?P.+)(?P[\\+-])(?P\d{2}):(?P\d{2})', - value, - flags=re.ASCII) - datetime_part = match.group('datetime') - tz_offset = datetime.timedelta(hours=int(match.group('hours')), - minutes=int(match.group('minutes'))) - tz_sign = -1 if match.group('sign') == '-' else 1 - tz_info = datetime.timezone(tz_sign * tz_offset) - return parse_datetime_literal(datetime_part).replace(tzinfo=tz_info) - except (ValueError, AttributeError): + normalized = value.upper().replace('Z', '+00:00') + if '.' in normalized: + dt_part, rest = normalized.split('.', 1) + frac, tz_suffix = rest[:6].ljust(6, '0'), rest[6:] + normalized = f'{dt_part}.{frac}{tz_suffix}' + return datetime.datetime.fromisoformat(normalized) + except ValueError: raise PyODataModelError(f'Cannot decode datetimeoffset from value {value}.') diff --git a/tests/test_model_v2.py b/tests/test_model_v2.py index 258dde77..d2e9bc5c 100644 --- a/tests/test_model_v2.py +++ b/tests/test_model_v2.py @@ -491,6 +491,7 @@ def test_traits(): ('2001-02-03T04:05:06.000007', datetime(2001, 2, 3, 4, 5, 6, microsecond=7)), ('2001-02-03T04:05:06', datetime(2001, 2, 3, 4, 5, 6, 0)), ('2001-02-03T04:05', datetime(2001, 2, 3, 4, 5, 0, 0)), + ('2001-02-03T04:05:06.1', datetime(2001, 2, 3, 4, 5, 6, microsecond=100000)), ]) def test_parse_datetime_literal(datetime_literal, expected): assert parse_datetime_literal(datetime_literal) == expected @@ -561,6 +562,15 @@ def test_traits_datetime(type_date_time): assert testdate.microsecond == 0 assert testdate.tzinfo == timezone.utc + # parsing sub-6-digit fractional seconds + testdate = type_date_time.traits.from_literal("datetime'1976-11-23T03:33:06.1'") + assert testdate.second == 6 + assert testdate.microsecond == 100000 + assert testdate.tzinfo == timezone.utc + + # None returns None + assert type_date_time.traits.from_literal(None) is None + # parsing invalid value with pytest.raises(PyODataModelError) as e_info: type_date_time.traits.from_literal('xyz') @@ -732,6 +742,8 @@ def test_traits_datetimeoffset_to_json(type_date_time_offset, python_datetime, e ("datetimeoffset'1976-11-23t03:33:06+12:00'", datetime(1976, 11, 23, 3, 33, 6, tzinfo=timezone(timedelta(hours=12))), 'On dateline'), ("datetimeoffset'1976-11-23t03:33:06-12:00'", datetime(1976, 11, 23, 3, 33, 6, tzinfo=timezone(timedelta(hours=-12))), 'Minimum offset'), ("datetimeoffset'1976-11-23t03:33:06+14:00'", datetime(1976, 11, 23, 3, 33, 6, tzinfo=timezone(timedelta(hours=14))), 'Maximum offset'), + ("datetimeoffset'1976-11-23T03:33+01:00'", datetime(1976, 11, 23, 3, 33, 0, tzinfo=timezone(timedelta(hours=1))), 'No seconds with offset'), + ("datetimeoffset'1976-11-23T03:33:06.654321Z'", datetime(1976, 11, 23, 3, 33, 6, microsecond=654321, tzinfo=timezone.utc), 'Microseconds with Z'), ]) def test_traits_datetimeoffset_from_literal(type_date_time_offset, literal, expected, comment): """Test Edm.DateTimeOffset trait: literal -> Python""" @@ -749,6 +761,10 @@ def test_traits_datetimeoffset_from_invalid_literal(type_date_time_offset): assert str(e_info.value).startswith('Cannot decode datetimeoffset from value xyz') +def test_traits_datetimeoffset_from_literal_none(type_date_time_offset): + assert type_date_time_offset.traits.from_literal(None) is None + + def test_traits_datetimeoffset_from_json(type_date_time_offset): """Test Edm.DateTimeOffset trait: OData -> Python""" diff --git a/tests/test_model_v2_EdmStructTypeSerializer.py b/tests/test_model_v2_EdmStructTypeSerializer.py index e27b320b..9b6eee77 100644 --- a/tests/test_model_v2_EdmStructTypeSerializer.py +++ b/tests/test_model_v2_EdmStructTypeSerializer.py @@ -12,7 +12,7 @@ def complex_type_property_declarations(): 'TestString': (Types.parse_type_name('Edm.String'), "'FooBar'", "'FooBar'", 'FooBar'), 'TestBoolean': (Types.parse_type_name('Edm.Boolean'), False, 'false', False), 'TestInt64': (Types.parse_type_name('Edm.Int64'), '123L', '123L', 123), - 'TestDateTime': (Types.parse_type_name('Edm.DateTime'), "/Date(2147483647000)/", "datetime'2038-01-19T3:14:7'", + 'TestDateTime': (Types.parse_type_name('Edm.DateTime'), "/Date(2147483647000)/", "datetime'2038-01-19T03:14:07'", datetime.datetime(2038, 1, 19, hour=3, minute=14, second=7, tzinfo=datetime.timezone.utc)) }