Skip to content

Commit ec56995

Browse files
model: fix timezone handling for Edm.DateTimeOffset (#184)
* model: fix timezone handling for Edm.DateTimeOffset Convert Edm.DateTimeOffset entities to Python datetime objects with a timezone attached, instead of simply passing them around as plain string. * model: encourage safe Edm.DateTime usage Edm.DateTime is underspecified and its timezone handling, if existing at all, up to interpretation. The current implementation allows creating Edm.DateTime properties, using a datetime object with an arbitrary timezone. Before sending the containing entity to the server, pyodata converts the Python datetime object to a UTC based representation. Therefore, when fetching the entity later on, its Edm.DateTime property will be UTC based, with the original timezone information being missing. This kind of information loss might surprise users of this library! Disallowing the creation of non-UTC datetime properties forces the user to convert manually to UTC, therefore raising awareness of the limitations of Edm.DateTime and encourage the usage of Edm.DateTimeOffset. For compatibility with (maybe existing) implementations that interpret the specs differently and add offsets to Edm.DateTime, pyodata now supports such values when being received from an OData server, converts them to UTC. Co-authored-by: Petr Hanák <[email protected]>
1 parent 674fab0 commit ec56995

File tree

6 files changed

+489
-115
lines changed

6 files changed

+489
-115
lines changed

CHANGELOG.md

+4-2
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,15 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
55
and this project adheres to [Semantic Versioning](http://semver.org/).
66

77
## [Unreleased]
8-
- model: split properties schema.entity_types/complex_types and their generated Collections - Petr Hanak
98

109
### Fixed
1110
- Fix Edm.Binary literal representation - Daniel Balko
11+
- Datetime support for Edm.DateTimeOffset - Reto Schneider
12+
- Disallow creation of non-UTC Edm.DateTime - Reto Schneider
13+
- Split properties schema.entity_types/complex_types and their generated Collections - Petr Hanak
1214

1315
### Removed
14-
- Python 3.6 (after its EOL) is no longer supported by pyodata. Python 3.7 is now minimal supported version.
16+
- Python 3.6 (after its EOL) is no longer supported by pyodata. Python 3.7 is now minimal supported version. - Petr Hanak
1517

1618
## [1.7.1]
1719

pyodata/v2/model.py

+138-45
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,6 @@
2929
TypeInfo = collections.namedtuple('TypeInfo', 'namespace name is_collection')
3030

3131

32-
def current_timezone():
33-
"""Default Timezone for Python datetime instances when parsed from
34-
Edm.DateTime values and vice versa.
35-
36-
OData V2 does not mention Timezones in the documentation of
37-
Edm.DateTime and UTC was chosen because it is universal.
38-
"""
39-
40-
return datetime.timezone.utc
41-
42-
4332
def modlog():
4433
return logging.getLogger(LOGGER_NAME)
4534

@@ -210,7 +199,8 @@ def _build_types():
210199
Types.register_type(Typ('Edm.SByte', '0'))
211200
Types.register_type(Typ('Edm.String', '\'\'', EdmStringTypTraits()))
212201
Types.register_type(Typ('Edm.Time', 'time\'PT00H00M\''))
213-
Types.register_type(Typ('Edm.DateTimeOffset', 'datetimeoffset\'0000-00-00T00:00:00\''))
202+
Types.register_type(
203+
Typ('Edm.DateTimeOffset', 'datetimeoffset\'0000-00-00T00:00:00Z\'', EdmDateTimeOffsetTypTraits()))
214204

215205
@staticmethod
216206
def register_type(typ):
@@ -373,6 +363,40 @@ def from_literal(self, value):
373363
return base64.b64encode(binary).decode()
374364

375365

366+
def ms_since_epoch_to_datetime(value, tzinfo):
367+
"""Convert milliseconds since midnight 1.1.1970 to datetime"""
368+
try:
369+
# https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function
370+
return datetime.datetime(1970, 1, 1, tzinfo=tzinfo) + datetime.timedelta(milliseconds=int(value))
371+
except (ValueError, OverflowError):
372+
min_ticks = -62135596800000
373+
max_ticks = 253402300799999
374+
if FIX_SCREWED_UP_MINIMAL_DATETIME_VALUE and int(value) < min_ticks:
375+
# Some service providers return false minimal date values.
376+
# -62135596800000 is the lowest value PyOData could read.
377+
# This workaround fixes this issue and returns 0001-01-01 00:00:00+00:00 in such a case.
378+
return datetime.datetime(year=1, day=1, month=1, tzinfo=tzinfo)
379+
if FIX_SCREWED_UP_MAXIMUM_DATETIME_VALUE and int(value) > max_ticks:
380+
return datetime.datetime(year=9999, day=31, month=12, tzinfo=tzinfo)
381+
raise PyODataModelError(f'Cannot decode datetime from value {value}. '
382+
f'Possible value range: {min_ticks} to {max_ticks}. '
383+
f'You may fix this by setting `FIX_SCREWED_UP_MINIMAL_DATETIME_VALUE` '
384+
f' or `FIX_SCREWED_UP_MAXIMUM_DATETIME_VALUE` as a workaround.')
385+
386+
387+
def parse_datetime_literal(value):
388+
try:
389+
return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')
390+
except ValueError:
391+
try:
392+
return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S')
393+
except ValueError:
394+
try:
395+
return datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M')
396+
except ValueError:
397+
raise PyODataModelError(f'Cannot decode datetime from value {value}.')
398+
399+
376400
class EdmDateTimeTypTraits(EdmPrefixedTypTraits):
377401
"""Emd.DateTime traits
378402
@@ -403,46 +427,48 @@ def to_literal(self, value):
403427
raise PyODataModelError(
404428
f'Cannot convert value of type {type(value)} to literal. Datetime format is required.')
405429

430+
if value.tzinfo != datetime.timezone.utc:
431+
raise PyODataModelError('Emd.DateTime accepts only UTC')
432+
406433
# Sets timezone to none to avoid including timezone information in the literal form.
407434
return super(EdmDateTimeTypTraits, self).to_literal(value.replace(tzinfo=None).isoformat())
408435

409436
def to_json(self, value):
410437
if isinstance(value, str):
411438
return value
412439

440+
if value.tzinfo != datetime.timezone.utc:
441+
raise PyODataModelError('Emd.DateTime accepts only UTC')
442+
413443
# Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification
414444
# https://www.odata.org/documentation/odata-version-2-0/json-format/
415-
return f'/Date({int(value.replace(tzinfo=current_timezone()).timestamp()) * 1000})/'
445+
# See also: https://docs.python.org/3/library/datetime.html#datetime.datetime.timestamp
446+
ticks = (value - datetime.datetime(1970, 1, 1, tzinfo=datetime.timezone.utc)) / datetime.timedelta(milliseconds=1)
447+
return f'/Date({int(ticks)})/'
416448

417449
def from_json(self, value):
418450

419451
if value is None:
420452
return None
421453

422-
matches = re.match(r"^/Date\((.*)\)/$", value)
423-
if not matches:
454+
matches = re.match(r"^/Date\((?P<milliseconds_since_epoch>-?\d+)(?P<offset_in_minutes>[+-]\d+)?\)/$", value)
455+
try:
456+
milliseconds_since_epoch = matches.group('milliseconds_since_epoch')
457+
except AttributeError:
424458
raise PyODataModelError(
425-
f"Malformed value {value} for primitive Edm type. Expected format is /Date(value)/")
426-
value = matches.group(1)
427-
459+
f"Malformed value {value} for primitive Edm.DateTime type."
460+
" Expected format is /Date(<ticks>[±<offset>])/")
428461
try:
429-
# https://stackoverflow.com/questions/36179914/timestamp-out-of-range-for-platform-localtime-gmtime-function
430-
value = datetime.datetime(1970, 1, 1, tzinfo=current_timezone()) + datetime.timedelta(milliseconds=int(value))
431-
except (ValueError, OverflowError):
432-
if FIX_SCREWED_UP_MINIMAL_DATETIME_VALUE and int(value) < -62135596800000:
433-
# Some service providers return false minimal date values.
434-
# -62135596800000 is the lowest value PyOData could read.
435-
# This workaroud fixes this issue and returns 0001-01-01 00:00:00+00:00 in such a case.
436-
value = datetime.datetime(year=1, day=1, month=1, tzinfo=current_timezone())
437-
elif FIX_SCREWED_UP_MAXIMUM_DATETIME_VALUE and int(value) > 253402300799999:
438-
value = datetime.datetime(year=9999, day=31, month=12, tzinfo=current_timezone())
439-
else:
440-
raise PyODataModelError(f'Cannot decode datetime from value {value}. '
441-
f'Possible value range: -62135596800000 to 253402300799999. '
442-
f'You may fix this by setting `FIX_SCREWED_UP_MINIMAL_DATETIME_VALUE` '
443-
f' or `FIX_SCREWED_UP_MAXIMUM_DATETIME_VALUE` as a workaround.')
444-
445-
return value
462+
offset_in_minutes = int(matches.group('offset_in_minutes') or 0)
463+
timedelta = datetime.timedelta(minutes=offset_in_minutes)
464+
except ValueError:
465+
raise PyODataModelError(
466+
f"Malformed value {value} for primitive Edm.DateTime type."
467+
" Expected format is /Date(<ticks>[±<offset>])/")
468+
except AttributeError:
469+
timedelta = datetime.timedelta() # Missing offset is interpreted as UTC
470+
# Might raise a PyODataModelError exception
471+
return ms_since_epoch_to_datetime(milliseconds_since_epoch, datetime.timezone.utc) + timedelta
446472

447473
def from_literal(self, value):
448474

@@ -451,18 +477,85 @@ def from_literal(self, value):
451477

452478
value = super(EdmDateTimeTypTraits, self).from_literal(value)
453479

480+
# Note: parse_datetime_literal raises a PyODataModelError exception on invalid formats
481+
return parse_datetime_literal(value).replace(tzinfo=datetime.timezone.utc)
482+
483+
484+
class EdmDateTimeOffsetTypTraits(EdmPrefixedTypTraits):
485+
"""Emd.DateTimeOffset traits
486+
487+
Represents date and time, plus an offset in minutes from UTC, with values ranging from 12:00:00 midnight,
488+
January 1, 1753 A.D. through 11:59:59 P.M, December 9999 A.D
489+
490+
Literal forms:
491+
datetimeoffset'yyyy-mm-ddThh:mm[:ss]±ii:nn' (works for all time zones)
492+
datetimeoffset'yyyy-mm-ddThh:mm[:ss]Z' (works only for UTC)
493+
NOTE: Spaces are not allowed between datetimeoffset and quoted portion.
494+
The datetime part is case-insensitive, the offset one is not.
495+
496+
Example 1: datetimeoffset'1970-01-01T00:00:01+00:30'
497+
- /Date(1000+0030)/ (As DateTime, but with a 30 minutes timezone offset)
498+
Example 1: datetimeoffset'1970-01-01T00:00:01-00:60'
499+
- /Date(1000-0030)/ (As DateTime, but with a negative 60 minutes timezone offset)
500+
https://blogs.sap.com/2017/01/05/date-and-time-in-sap-gateway-foundation/
501+
"""
502+
503+
def __init__(self):
504+
super(EdmDateTimeOffsetTypTraits, self).__init__('datetimeoffset')
505+
506+
def to_literal(self, value):
507+
"""Convert python datetime representation to literal format"""
508+
509+
if not isinstance(value, datetime.datetime) or value.utcoffset() is None:
510+
raise PyODataModelError(
511+
f'Cannot convert value of type {type(value)} to literal. Datetime format including offset is required.')
512+
513+
return super(EdmDateTimeOffsetTypTraits, self).to_literal(value.isoformat())
514+
515+
def to_json(self, value):
516+
# datetime.timestamp() does not work due to its limited precision
517+
offset_in_minutes = int(value.utcoffset() / datetime.timedelta(minutes=1))
518+
ticks = int((value - datetime.datetime(1970, 1, 1, tzinfo=value.tzinfo)) / datetime.timedelta(milliseconds=1))
519+
return f'/Date({ticks}{offset_in_minutes:+05})/'
520+
521+
def from_json(self, value):
522+
matches = re.match(r"^/Date\((?P<milliseconds_since_epoch>-?\d+)(?P<offset_in_minutes>[+-]\d+)\)/$", value)
454523
try:
455-
value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%f')
456-
except ValueError:
457-
try:
458-
value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M:%S')
459-
except ValueError:
460-
try:
461-
value = datetime.datetime.strptime(value, '%Y-%m-%dT%H:%M')
462-
except ValueError:
463-
raise PyODataModelError(f'Cannot decode datetime from value {value}.')
524+
milliseconds_since_epoch = matches.group('milliseconds_since_epoch')
525+
offset_in_minutes = int(matches.group('offset_in_minutes'))
526+
except (ValueError, AttributeError):
527+
raise PyODataModelError(
528+
f"Malformed value {value} for primitive Edm.DateTimeOffset type."
529+
" Expected format is /Date(<ticks>±<offset>)/")
530+
531+
tzinfo = datetime.timezone(datetime.timedelta(minutes=offset_in_minutes))
532+
# Might raise a PyODataModelError exception
533+
return ms_since_epoch_to_datetime(milliseconds_since_epoch, tzinfo)
534+
535+
def from_literal(self, value):
464536

465-
return value.replace(tzinfo=current_timezone())
537+
if value is None:
538+
return None
539+
540+
value = super(EdmDateTimeOffsetTypTraits, self).from_literal(value)
541+
542+
try:
543+
# Note: parse_datetime_literal raises a PyODataModelError exception on invalid formats
544+
if re.match(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z', value, flags=re.ASCII | re.IGNORECASE):
545+
datetime_part = value[:-1]
546+
tz_info = datetime.timezone.utc
547+
else:
548+
match = re.match(r'(?P<datetime>.+)(?P<sign>[\\+-])(?P<hours>\d{2}):(?P<minutes>\d{2})',
549+
value,
550+
flags=re.ASCII)
551+
datetime_part = match.group('datetime')
552+
tz_offset = datetime.timedelta(hours=int(match.group('hours')),
553+
minutes=int(match.group('minutes')))
554+
tz_sign = -1 if match.group('sign') == '-' else 1
555+
tz_info = datetime.timezone(tz_sign * tz_offset)
556+
return parse_datetime_literal(datetime_part).replace(tzinfo=tz_info)
557+
except (ValueError, AttributeError):
558+
raise PyODataModelError(f'Cannot decode datetimeoffset from value {value}.')
466559

467560

468561
class EdmStringTypTraits(TypTraits):

tests/conftest.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import logging
33
import os
44
import pytest
5-
from pyodata.v2.model import schema_from_xml
5+
from pyodata.v2.model import schema_from_xml, Types
66

77

88
def contents_of_fixtures_file(file_name):
@@ -129,3 +129,13 @@ def assert_logging_policy(mock_warning, *args):
129129
def assert_request_contains_header(headers, name, value):
130130
assert name in headers
131131
assert headers[name] == value
132+
133+
134+
@pytest.fixture
135+
def type_date_time():
136+
return Types.from_name('Edm.DateTime')
137+
138+
139+
@pytest.fixture
140+
def type_date_time_offset():
141+
return Types.from_name('Edm.DateTimeOffset')

tests/metadata.xml

+2
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
sap:creatable="false" sap:updatable="false" sap:sortable="true" sap:filterable="true"/>
6060
<Property Name="Date" Type="Edm.DateTime" Nullable="false" sap:label="Data" sap:creatable="false"
6161
sap:updatable="false" sap:sortable="true" sap:filterable="true"/>
62+
<Property Name="DateTimeWithOffset" Type="Edm.DateTimeOffset" Nullable="true" sap:label="Data"
63+
sap:creatable="false" sap:updatable="true" sap:sortable="true" sap:filterable="true"/>
6264
<Property Name="Value" Type="Edm.Double" Nullable="false" sap:unicode="false" sap:label="Data"
6365
sap:creatable="false" sap:updatable="false" sap:sortable="true" sap:filterable="true"/>
6466
</EntityType>

0 commit comments

Comments
 (0)