Skip to content

Commit 95e36b2

Browse files
msgpack: support tzoffset in datetime
Support non-zero tzoffset in datetime extended type. If tzoffset and tzindex are not specified, return object with timezone-naive pandas.Timestamp internals. If tzoffset is specified, return object with timezone-aware pandas.Timestamp with pytz.FixedOffset [1] timezone info. pytz module is already a dependency of pandas, but this patch adds it as a requirement just in case something will change in the future. pandas >= 1.0.0 restriction was added to ensure that Timestamp.tz() setter is disabled. 1. https://pypi.org/project/pytz/ Part of #204
1 parent 5a8e88f commit 95e36b2

File tree

4 files changed

+111
-8
lines changed

4 files changed

+111
-8
lines changed

Diff for: CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
- Decimal type support (#203).
1111
- UUID type support (#202).
1212
- Datetime type support and tarantool.Datetime type (#204).
13+
- Offset in datetime type support (#204).
1314

1415
### Changed
1516
- Bump msgpack requirement to 1.0.4 (PR #223).

Diff for: requirements.txt

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
msgpack>=1.0.4
2-
pandas
2+
pandas>=1.0.0
3+
pytz

Diff for: tarantool/msgpack_ext/types/datetime.py

+51-7
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pandas
2+
import pytz
23

34
# https://www.tarantool.io/ru/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type
45
#
@@ -39,7 +40,16 @@
3940
BYTEORDER = 'little'
4041

4142
NSEC_IN_SEC = 1000000000
43+
SEC_IN_MIN = 60
44+
MIN_IN_DAY = 60 * 24
4245

46+
def compute_offset(dt):
47+
if dt.tz is None:
48+
return 0
49+
50+
utc_offset = dt.tz.utcoffset(dt)
51+
# There is no precision loss since pytz.FixedOffset is in minutes
52+
return utc_offset.days * MIN_IN_DAY + utc_offset.seconds // SEC_IN_MIN
4353

4454
def get_bytes_as_int(data, cursor, size):
4555
part = data[cursor:cursor + size]
@@ -61,22 +71,35 @@ def msgpack_decode(data):
6171
tzoffset = 0
6272
tzindex = 0
6373

64-
if (tzoffset != 0) or (tzindex != 0):
65-
raise NotImplementedError
66-
6774
total_nsec = seconds * NSEC_IN_SEC + nsec
6875

69-
dt = pandas.to_datetime(total_nsec, unit='ns')
76+
if (tzindex != 0):
77+
raise NotImplementedError
78+
elif (tzoffset != 0):
79+
tzinfo = pytz.FixedOffset(tzoffset)
80+
dt = pandas.to_datetime(total_nsec, unit='ns').replace(tzinfo=pytz.utc).tz_convert(tzinfo)
81+
else:
82+
# return timezone-naive pandas.Timestamp
83+
dt = pandas.to_datetime(total_nsec, unit='ns')
84+
7085
return dt, tzoffset, tzindex
7186

7287
class Datetime(pandas.Timestamp):
7388
def __new__(cls, *args, **kwargs):
74-
if len(args) > 0 and isinstance(args[0], bytes):
75-
dt, tzoffset, tzindex = msgpack_decode(args[0])
76-
else:
89+
dt = None
90+
if len(args) > 0:
91+
if isinstance(args[0], bytes):
92+
dt, tzoffset, tzindex = msgpack_decode(args[0])
93+
elif isinstance(args[0], Datetime):
94+
dt = pandas.Timestamp.__new__(cls, *args, **kwargs)
95+
tzoffset = args[0].tarantool_tzoffset
96+
97+
if dt is None:
7798
dt = super().__new__(cls, *args, **kwargs)
99+
tzoffset = compute_offset(dt)
78100

79101
dt.__class__ = cls
102+
dt.tarantool_tzoffset = tzoffset
80103
return dt
81104

82105
def msgpack_encode(self):
@@ -85,6 +108,11 @@ def msgpack_encode(self):
85108
tzoffset = 0
86109
tzindex = 0
87110

111+
if isinstance(self, Datetime):
112+
tzoffset = self.tarantool_tzoffset
113+
else:
114+
tzoffset = compute_offset(self)
115+
88116
buf = get_int_as_bytes(seconds, SECONDS_SIZE_BYTES)
89117

90118
if (nsec != 0) or (tzoffset != 0) or (tzindex != 0):
@@ -93,3 +121,19 @@ def msgpack_encode(self):
93121
buf = buf + get_int_as_bytes(tzindex, TZINDEX_SIZE_BYTES)
94122

95123
return buf
124+
125+
def replace(self, *args, **kwargs):
126+
dt = super().replace(*args, **kwargs)
127+
return Datetime(dt)
128+
129+
def astimezone(self, *args, **kwargs):
130+
dt = super().astimezone(*args, **kwargs)
131+
return Datetime(dt)
132+
133+
def tz_convert(self, *args, **kwargs):
134+
dt = super().tz_convert(*args, **kwargs)
135+
return Datetime(dt)
136+
137+
def tz_localize(self, *args, **kwargs):
138+
dt = super().tz_localize(*args, **kwargs)
139+
return Datetime(dt)

Diff for: test/suites/test_msgpack_ext.py

+57
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import warnings
1111
import tarantool
1212
import pandas
13+
import pytz
1314

1415
from tarantool.msgpack_ext.packer import default as packer_default
1516
from tarantool.msgpack_ext.unpacker import ext_hook as unpacker_ext_hook
@@ -559,6 +560,62 @@ def test_UUID_tarantool_encode(self):
559560
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
560561
r"nsec=308543321})",
561562
},
563+
'datetime_with_positive_offset': {
564+
'python': tarantool.Datetime(year=2022, month=8, day=31, hour=18, minute=7, second=54,
565+
microsecond=308543, nanosecond=321,
566+
tzinfo=pytz.FixedOffset(180)),
567+
'msgpack': (b'\x4a\x79\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xb4\x00\x00\x00'),
568+
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
569+
r"nsec=308543321, tzoffset=180})",
570+
},
571+
'datetime_with_negative_offset': {
572+
'python': tarantool.Datetime(year=2022, month=8, day=31, hour=18, minute=7, second=54,
573+
microsecond=308543, nanosecond=321,
574+
tzinfo=pytz.FixedOffset(-60)),
575+
'msgpack': (b'\x8a\xb1\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xc4\xff\x00\x00'),
576+
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
577+
r"nsec=308543321, tzoffset=-60})",
578+
},
579+
'pandas_timestamp_with_positive_offset': {
580+
'python': pandas.Timestamp(year=2022, month=8, day=31, hour=18, minute=7, second=54,
581+
microsecond=308543, nanosecond=321,
582+
tzinfo=pytz.FixedOffset(180)),
583+
'msgpack': (b'\x4a\x79\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xb4\x00\x00\x00'),
584+
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
585+
r"nsec=308543321, tzoffset=180})",
586+
},
587+
'pandas_timestamp_with_negative_offset': {
588+
'python': pandas.Timestamp(year=2022, month=8, day=31, hour=18, minute=7, second=54,
589+
microsecond=308543, nanosecond=321,
590+
tzinfo=pytz.FixedOffset(-60)),
591+
'msgpack': (b'\x8a\xb1\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xc4\xff\x00\x00'),
592+
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
593+
r"nsec=308543321, tzoffset=-60})",
594+
},
595+
'datetime_offset_replace': {
596+
'python': tarantool.Datetime(year=2022, month=8, day=31, hour=18, minute=7, second=54,
597+
microsecond=308543, nanosecond=321,
598+
).replace(tzinfo=pytz.FixedOffset(180)),
599+
'msgpack': (b'\x4a\x79\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xb4\x00\x00\x00'),
600+
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
601+
r"nsec=308543321, tzoffset=180})",
602+
},
603+
'datetime_offset_convert': {
604+
'python': tarantool.Datetime(year=2022, month=8, day=31, hour=16, minute=7, second=54,
605+
microsecond=308543, nanosecond=321,
606+
tzinfo=pytz.FixedOffset(60)).tz_convert(pytz.FixedOffset(180)),
607+
'msgpack': (b'\x4a\x79\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xb4\x00\x00\x00'),
608+
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
609+
r"nsec=308543321, tzoffset=180})",
610+
},
611+
'datetime_offset_astimezone': {
612+
'python': tarantool.Datetime(year=2022, month=8, day=31, hour=16, minute=7, second=54,
613+
microsecond=308543, nanosecond=321,
614+
tzinfo=pytz.FixedOffset(60)).astimezone(pytz.FixedOffset(180)),
615+
'msgpack': (b'\x4a\x79\x0f\x63\x00\x00\x00\x00\x59\xff\x63\x12\xb4\x00\x00\x00'),
616+
'tarantool': r"datetime.new({year=2022, month=8, day=31, hour=18, min=7, sec=54, " +
617+
r"nsec=308543321, tzoffset=180})",
618+
},
562619
}
563620

564621
def test_datetime_msgpack_decode(self):

0 commit comments

Comments
 (0)