Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
38 changes: 14 additions & 24 deletions pyodata/v2/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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<datetime>.+)(?P<sign>[\\+-])(?P<hours>\d{2}):(?P<minutes>\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}.')


Expand Down
16 changes: 16 additions & 0 deletions tests/test_model_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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"""
Expand All @@ -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"""

Expand Down
2 changes: 1 addition & 1 deletion tests/test_model_v2_EdmStructTypeSerializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
Loading