29
29
TypeInfo = collections .namedtuple ('TypeInfo' , 'namespace name is_collection' )
30
30
31
31
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
-
43
32
def modlog ():
44
33
return logging .getLogger (LOGGER_NAME )
45
34
@@ -210,7 +199,8 @@ def _build_types():
210
199
Types .register_type (Typ ('Edm.SByte' , '0' ))
211
200
Types .register_type (Typ ('Edm.String' , '\' \' ' , EdmStringTypTraits ()))
212
201
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 ()))
214
204
215
205
@staticmethod
216
206
def register_type (typ ):
@@ -373,6 +363,40 @@ def from_literal(self, value):
373
363
return base64 .b64encode (binary ).decode ()
374
364
375
365
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
+
376
400
class EdmDateTimeTypTraits (EdmPrefixedTypTraits ):
377
401
"""Emd.DateTime traits
378
402
@@ -403,46 +427,48 @@ def to_literal(self, value):
403
427
raise PyODataModelError (
404
428
f'Cannot convert value of type { type (value )} to literal. Datetime format is required.' )
405
429
430
+ if value .tzinfo != datetime .timezone .utc :
431
+ raise PyODataModelError ('Emd.DateTime accepts only UTC' )
432
+
406
433
# Sets timezone to none to avoid including timezone information in the literal form.
407
434
return super (EdmDateTimeTypTraits , self ).to_literal (value .replace (tzinfo = None ).isoformat ())
408
435
409
436
def to_json (self , value ):
410
437
if isinstance (value , str ):
411
438
return value
412
439
440
+ if value .tzinfo != datetime .timezone .utc :
441
+ raise PyODataModelError ('Emd.DateTime accepts only UTC' )
442
+
413
443
# Converts datetime into timestamp in milliseconds in UTC timezone as defined in ODATA specification
414
444
# 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 )} )/'
416
448
417
449
def from_json (self , value ):
418
450
419
451
if value is None :
420
452
return None
421
453
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 :
424
458
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>])/" )
428
461
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
446
472
447
473
def from_literal (self , value ):
448
474
@@ -451,18 +477,85 @@ def from_literal(self, value):
451
477
452
478
value = super (EdmDateTimeTypTraits , self ).from_literal (value )
453
479
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 )
454
523
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 ):
464
536
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 } .' )
466
559
467
560
468
561
class EdmStringTypTraits (TypTraits ):
0 commit comments