2929TypeInfo = 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-
4332def 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+
376400class 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
468561class EdmStringTypTraits (TypTraits ):
0 commit comments