Skip to content

Commit

Permalink
msgpack: support datetime interval extended type
Browse files Browse the repository at this point in the history
Tarantool supports datetime interval type since version 2.10.0 [1].
This patch introduced the support of Tarantool interval type in
msgpack decoders and encoders.

The Tarantool interval type is mapped to new tarantool.Interval type.
datetime, numpy and pandas tools doesn't seem to be sufficient to
cover all adjust cases supported by Tarantool.

This patch does not yet introduce the support of datetime interval
arithmetic.

1. tarantool/tarantool#5941

Part of #229
  • Loading branch information
DifferentialOrange committed Sep 9, 2022
1 parent c3aca7f commit 5f6689a
Show file tree
Hide file tree
Showing 8 changed files with 387 additions and 2 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
They are treated as `pytz.FixedOffset` on encoding. The warning is
is raised in this case.

- Datetime interval type support and tarantool.Interval type (#229).

### Changed
- Bump msgpack requirement to 1.0.4 (PR #223).
The only reason of this bump is various vulnerability fixes,
Expand Down
7 changes: 6 additions & 1 deletion tarantool/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
Datetime,
)

from tarantool.msgpack_ext.types.interval import (
Adjust as IntervalAdjust,
Interval,
)

__version__ = "0.9.0"


Expand Down Expand Up @@ -95,7 +100,7 @@ def connectmesh(addrs=({'host': 'localhost', 'port': 3301},), user=None,

__all__ = ['connect', 'Connection', 'connectmesh', 'MeshConnection', 'Schema',
'Error', 'DatabaseError', 'NetworkError', 'NetworkWarning',
'SchemaError', 'dbapi', 'Datetime']
'SchemaError', 'dbapi', 'Datetime', 'Interval', 'IntervalAdjust']

# ConnectionPool is supported only for Python 3.7 or newer.
if sys.version_info.major >= 3 and sys.version_info.minor >= 7:
Expand Down
9 changes: 9 additions & 0 deletions tarantool/msgpack_ext/interval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from tarantool.msgpack_ext.types.interval import Interval

EXT_ID = 6

def encode(obj):
return obj.msgpack_encode()

def decode(data):
return Interval(data)
7 changes: 7 additions & 0 deletions tarantool/msgpack_ext/packer.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import pandas

from tarantool.msgpack_ext.types.datetime import Datetime
from tarantool.msgpack_ext.types.interval import Interval

import tarantool.msgpack_ext.decimal as ext_decimal
import tarantool.msgpack_ext.uuid as ext_uuid
import tarantool.msgpack_ext.datetime as ext_datetime
import tarantool.msgpack_ext.interval as ext_interval

encoders = [
{
Expand All @@ -30,6 +32,11 @@
'ext_id': ext_datetime.EXT_ID,
'encoder': ext_datetime.encode_pd_timestamp,
},
{
'type': Interval,
'ext_id': ext_interval.EXT_ID,
'encoder': ext_interval.encode,
},
]

def default(obj):
Expand Down
152 changes: 152 additions & 0 deletions tarantool/msgpack_ext/types/interval.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import msgpack
from enum import Enum

from tarantool.error import MsgpackError

# https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-interval-type
#
# The interval MessagePack representation looks like this:
# +--------+-------------------------+-------------+----------------+
# | MP_EXT | Size of packed interval | MP_INTERVAL | PackedInterval |
# +--------+-------------------------+-------------+----------------+
# Packed interval consists of:
# - Packed number of non-zero fields.
# - Packed non-null fields.
#
# Each packed field has the following structure:
# +----------+=====================+
# | field ID | field value |
# +----------+=====================+
#
# The number of defined (non-null) fields can be zero. In this case,
# the packed interval will be encoded as integer 0.
#
# List of the field IDs:
# - 0 – year
# - 1 – month
# - 2 – week
# - 3 – day
# - 4 – hour
# - 5 – minute
# - 6 – second
# - 7 – nanosecond
# - 8 – adjust

id_map = {
0: 'year',
1: 'month',
2: 'week',
3: 'day',
4: 'hour',
5: 'minute',
6: 'second',
7: 'nanosecond',
8: 'adjust',
}

# https://github.com/tarantool/c-dt/blob/cec6acebb54d9e73ea0b99c63898732abd7683a6/dt_arithmetic.h#L34
class Adjust(Enum):
Excess = 0 # DT_EXCESS in c-dt, "excess" in Tarantool
Limit = 1 # DT_LIMIT in c-dt, "none" in Tarantool (None is a Python keyword, we can't use it)
Last = 2 # DT_SNAP in c-dt, "last" in Tarantool

class Interval():
def __init__(self, data=None, *, year=0, month=0, week=0,
day=0, hour=0, minute=0, second=0,
nanosecond=0, adjust=Adjust.Limit):
# If msgpack data does not contain a field value, it is zero.
# If built not from msgpack data, set argument values later.
self.year = 0
self.month = 0
self.week = 0
self.day = 0
self.hour = 0
self.minute = 0
self.second = 0
self.nanosecond = 0
self.adjust = Adjust(0)

if data is not None:
if len(data) == 0:
return

# To create an unpacker is the only way to parse
# a sequence of values in Python msgpack module.
unpacker = msgpack.Unpacker()
unpacker.feed(data)
field_count = unpacker.unpack()
for _ in range(field_count):
field_id = unpacker.unpack()
value = unpacker.unpack()

if field_id not in id_map:
raise MsgpackError(f'Unknown interval field id {field_id}')

field_name = id_map[field_id]

if field_name == 'adjust':
try:
value = Adjust(value)
except ValueError as e:
raise MsgpackError(e)

setattr(self, id_map[field_id], value)
else:
self.year = year
self.month = month
self.week = week
self.day = day
self.hour = hour
self.minute = minute
self.second = second
self.nanosecond = nanosecond
self.adjust = adjust

def __eq__(self, other):
if not isinstance(other, Interval):
return False

# Tarantool interval compare is naive too
#
# Tarantool 2.10.1-0-g482d91c66
#
# tarantool> datetime.interval.new{hour=1} == datetime.interval.new{min=60}
# ---
# - false
# ...

for field_id in id_map.keys():
field_name = id_map[field_id]
if getattr(self, field_name) != getattr(other, field_name):
return False

return True

def __str__(self):
return f'tarantool.Interval(year={self.year}, month={self.month}, day={self.day}, ' + \
f'hour={self.hour}, minute={self.minute}, second={self.second}, ' + \
f'nanosecond={self.nanosecond}, adjust={self.adjust})'

def __repr__(self):
return f'tarantool.Interval(year={self.year}, month={self.month}, day={self.day}, ' + \
f'hour={self.hour}, minute={self.minute}, second={self.second}, ' + \
f'nanosecond={self.nanosecond}, adjust={self.adjust})'

def msgpack_encode(self):
buf = bytes()

count = 0
for field_id in id_map.keys():
field_name = id_map[field_id]
value = getattr(self, field_name)

if field_name == 'adjust':
value = value.value

if value != 0:
buf = buf + msgpack.packb(field_id) + msgpack.packb(value)
count = count + 1

buf = msgpack.packb(count) + buf

return buf
2 changes: 2 additions & 0 deletions tarantool/msgpack_ext/unpacker.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import tarantool.msgpack_ext.decimal as ext_decimal
import tarantool.msgpack_ext.uuid as ext_uuid
import tarantool.msgpack_ext.datetime as ext_datetime
import tarantool.msgpack_ext.interval as ext_interval

decoders = {
ext_decimal.EXT_ID : ext_decimal.decode ,
ext_uuid.EXT_ID : ext_uuid.decode ,
ext_datetime.EXT_ID: ext_datetime.decode,
ext_interval.EXT_ID: ext_interval.decode,
}

def ext_hook(code, data):
Expand Down
4 changes: 3 additions & 1 deletion test/suites/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
from .test_decimal import TestSuite_Decimal
from .test_uuid import TestSuite_UUID
from .test_datetime import TestSuite_Datetime
from .test_interval import TestSuite_Interval

test_cases = (TestSuite_Schema_UnicodeConnection,
TestSuite_Schema_BinaryConnection,
TestSuite_Request, TestSuite_Protocol, TestSuite_Reconnect,
TestSuite_Mesh, TestSuite_Execute, TestSuite_DBAPI,
TestSuite_Encoding, TestSuite_Pool, TestSuite_Ssl,
TestSuite_Decimal, TestSuite_UUID, TestSuite_Datetime)
TestSuite_Decimal, TestSuite_UUID, TestSuite_Datetime,
TestSuite_Interval)

def load_tests(loader, tests, pattern):
suite = unittest.TestSuite()
Expand Down
Loading

0 comments on commit 5f6689a

Please sign in to comment.