diff --git a/CHANGELOG.md b/CHANGELOG.md index ad7c2cb6..614d4e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 You may use `tz` property to get timezone name of a datetime object. +- Datetime interval type support and tarantool.Interval type (#229). + + Tarantool datetime interval objects are decoded to `tarantool.Interval` + type. `tarantool.Interval` may be encoded to Tarantool interval + objects. + + You can create `tarantool.Interval` objects either from msgpack + data or by using the same API as in Tarantool: + + ```python + di = tarantool.Interval(year=-1, month=2, day=3, + hour=4, minute=-5, sec=6, + nsec=308543321, + adjust=tarantool.IntervalAdjust.NONE) + ``` + + Its attributes (same as in init API) are exposed, so you can + use them if needed. + +- Datetime interval arithmetic support (#229). + + Valid operations: + - `tarantool.Datetime` + `tarantool.Interval` = `tarantool.Datetime` + - `tarantool.Datetime` + `tarantool.Interval` = `tarantool.Datetime` + - `tarantool.Datetime` - `tarantool.Datetime` = `tarantool.Interval` + - `tarantool.Interval` + `tarantool.Interval` = `tarantool.Interval` + - `tarantool.Interval` - `tarantool.Interval` = `tarantool.Interval` + + Since `tarantool.Interval` could contain `month` and `year` fields + and such operations could be ambiguous, you can use `adjust` field + to tune the logic. The behavior is the same as in Tarantool, see + [Interval arithmetic RFC](https://github.com/tarantool/tarantool/wiki/Datetime-Internals#interval-arithmetic). + + - `tarantool.IntervalAdjust.NONE` -- only truncation toward the end of + month performed (default mode). + + ```python + >>> dt = tarantool.Datetime(year=2022, month=3, day=31) + datetime: Timestamp('2022-03-31 00:00:00'), tz: "" + >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.NONE) + >>> dt + di + datetime: Timestamp('2022-04-30 00:00:00'), tz: "" + ``` + + - `tarantool.IntervalAdjust.EXCESS` -- overflow mode, without any snap + or truncation to the end of month, straight addition of days in month, + stopping over month boundaries if there is less number of days. + + ```python + >>> dt = tarantool.Datetime(year=2022, month=1, day=31) + datetime: Timestamp('2022-01-31 00:00:00'), tz: "" + >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.EXCESS) + >>> dt + di + datetime: Timestamp('2022-03-02 00:00:00'), tz: "" + ``` + + - `tarantool.IntervalAdjust.LAST` -- mode when day snaps to the end of month, + if happens. + + ```python + >>> dt = tarantool.Datetime(year=2022, month=2, day=28) + datetime: Timestamp('2022-02-28 00:00:00'), tz: "" + >>> di = tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.LAST) + >>> dt + di + datetime: Timestamp('2022-03-31 00:00:00'), tz: "" + ``` + ### Changed - Bump msgpack requirement to 1.0.4 (PR #223). The only reason of this bump is various vulnerability fixes, diff --git a/tarantool/__init__.py b/tarantool/__init__.py index 6625b4eb..c0b50cfc 100644 --- a/tarantool/__init__.py +++ b/tarantool/__init__.py @@ -36,6 +36,11 @@ Datetime, ) +from tarantool.msgpack_ext.types.interval import ( + Adjust as IntervalAdjust, + Interval, +) + __version__ = "0.9.0" @@ -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: diff --git a/tarantool/msgpack_ext/interval.py b/tarantool/msgpack_ext/interval.py new file mode 100644 index 00000000..79b5a8de --- /dev/null +++ b/tarantool/msgpack_ext/interval.py @@ -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) diff --git a/tarantool/msgpack_ext/packer.py b/tarantool/msgpack_ext/packer.py index bff2b821..d41c411d 100644 --- a/tarantool/msgpack_ext/packer.py +++ b/tarantool/msgpack_ext/packer.py @@ -3,15 +3,18 @@ from msgpack import ExtType 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 = [ {'type': Decimal, 'ext': ext_decimal }, {'type': UUID, 'ext': ext_uuid }, {'type': Datetime, 'ext': ext_datetime}, + {'type': Interval, 'ext': ext_interval}, ] def default(obj): diff --git a/tarantool/msgpack_ext/types/datetime.py b/tarantool/msgpack_ext/types/datetime.py index 62774217..d89c1cae 100644 --- a/tarantool/msgpack_ext/types/datetime.py +++ b/tarantool/msgpack_ext/types/datetime.py @@ -6,6 +6,8 @@ import tarantool.msgpack_ext.types.timezones as tt_timezones from tarantool.error import MsgpackError +from tarantool.msgpack_ext.types.interval import Interval, Adjust + # https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-datetime-type # # The datetime MessagePack representation looks like this: @@ -47,6 +49,7 @@ NSEC_IN_SEC = 1000000000 NSEC_IN_MKSEC = 1000 SEC_IN_MIN = 60 +MONTH_IN_YEAR = 12 def get_bytes_as_int(data, cursor, size): part = data[cursor:cursor + size] @@ -168,6 +171,83 @@ def __init__(self, data=None, *, timestamp=None, year=None, month=None, self._datetime = datetime self._tz = '' + def _interval_operation(self, other, sign=1): + self_dt = self._datetime + + # https://github.com/tarantool/tarantool/wiki/Datetime-Internals#date-adjustions-and-leap-years + months = other.year * MONTH_IN_YEAR + other.month + + res = self_dt + pandas.DateOffset(months = sign * months) + + # pandas.DateOffset works exactly like Adjust.NONE + if other.adjust == Adjust.EXCESS: + if self_dt.day > res.day: + res = res + pandas.DateOffset(days = self_dt.day - res.day) + elif other.adjust == Adjust.LAST: + if self_dt.is_month_end: + # day replaces days + res = res.replace(day = res.days_in_month) + + res = res + pandas.Timedelta(weeks = sign * other.week, + days = sign * other.day, + hours = sign * other.hour, + minutes = sign * other.minute, + seconds = sign * other.sec, + microseconds = sign * (other.nsec // NSEC_IN_MKSEC), + nanoseconds = sign * (other.nsec % NSEC_IN_MKSEC)) + + if res.tzinfo is not None: + tzoffset = compute_offset(res) + else: + tzoffset = 0 + return Datetime(year=res.year, month=res.month, day=res.day, + hour=res.hour, minute=res.minute, sec=res.second, + nsec=res.nanosecond + res.microsecond * NSEC_IN_MKSEC, + tzoffset=tzoffset, tz=self.tz) + + def __add__(self, other): + if not isinstance(other, Interval): + raise TypeError(f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'") + + return self._interval_operation(other, sign=1) + + def __sub__(self, other): + if isinstance(other, Datetime): + self_dt = self._datetime + other_dt = other._datetime + + # Tarantool datetime subtraction ignores timezone info, but it is a bug: + # + # Tarantool 2.10.1-0-g482d91c66 + # + # tarantool> datetime.new{tz='MSK'} - datetime.new{tz='UTC'} + # --- + # - +0 seconds + # ... + # + # Refer to https://github.com/tarantool/tarantool/issues/7698 + # for possible updates. + + if self_dt.tzinfo != other_dt.tzinfo: + other_dt = other_dt.tz_convert(self_dt.tzinfo) + + self_nsec = self_dt.microsecond * NSEC_IN_MKSEC + self_dt.nanosecond + other_nsec = other_dt.microsecond * NSEC_IN_MKSEC + other_dt.nanosecond + + return Interval( + year = self_dt.year - other_dt.year, + month = self_dt.month - other_dt.month, + day = self_dt.day - other_dt.day, + hour = self_dt.hour - other_dt.hour, + minute = self_dt.minute - other_dt.minute, + sec = self_dt.second - other_dt.second, + nsec = self_nsec - other_nsec, + ) + elif isinstance(other, Interval): + return self._interval_operation(other, sign=-1) + else: + raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'") + def __eq__(self, other): if isinstance(other, Datetime): return self._datetime == other._datetime diff --git a/tarantool/msgpack_ext/types/interval.py b/tarantool/msgpack_ext/types/interval.py new file mode 100644 index 00000000..d7caeb9f --- /dev/null +++ b/tarantool/msgpack_ext/types/interval.py @@ -0,0 +1,217 @@ +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: 'sec', + 7: 'nsec', + 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 + NONE = 1 # DT_LIMIT in c-dt, "none" in Tarantool + 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, sec=0, + nsec=0, adjust=Adjust.NONE): + # 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.sec = 0 + self.nsec = 0 + self.adjust = Adjust(0) + + if data is not None: + if not isinstance(data, bytes): + raise ValueError('data argument (first positional argument) ' + + 'expected to be a "bytes" instance') + + 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.sec = sec + self.nsec = nsec + self.adjust = adjust + + def __add__(self, other): + if not isinstance(other, Interval): + raise TypeError(f"unsupported operand type(s) for +: '{type(self)}' and '{type(other)}'") + + # Tarantool saves adjust of the first argument + # + # Tarantool 2.10.1-0-g482d91c66 + # + # tarantool> dt1 = datetime.interval.new{year = 2, adjust='last'} + # --- + # ... + # + # tarantool> dt2 = datetime.interval.new{year = 1, adjust='excess'} + # --- + # ... + # + # tarantool> (dt1 + dt2).adjust + # --- + # - 'cdata: 2' + # ... + + return Interval( + year = self.year + other.year, + month = self.month + other.month, + day = self.day + other.day, + hour = self.hour + other.hour, + minute = self.minute + other.minute, + sec = self.sec + other.sec, + nsec = self.nsec + other.nsec, + adjust = self.adjust, + ) + + def __sub__(self, other): + if not isinstance(other, Interval): + raise TypeError(f"unsupported operand type(s) for -: '{type(self)}' and '{type(other)}'") + + # Tarantool saves adjust of the first argument + # + # Tarantool 2.10.1-0-g482d91c66 + # + # tarantool> dt1 = datetime.interval.new{year = 2, adjust='last'} + # --- + # ... + # + # tarantool> dt2 = datetime.interval.new{year = 1, adjust='excess'} + # --- + # ... + # + # tarantool> (dt1 - dt2).adjust + # --- + # - 'cdata: 2' + # ... + + return Interval( + year = self.year - other.year, + month = self.month - other.month, + day = self.day - other.day, + hour = self.hour - other.hour, + minute = self.minute - other.minute, + sec = self.sec - other.sec, + nsec = self.nsec - other.nsec, + adjust = self.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 __repr__(self): + return f'tarantool.Interval(year={self.year}, month={self.month}, day={self.day}, ' + \ + f'hour={self.hour}, minute={self.minute}, sec={self.sec}, ' + \ + f'nsec={self.nsec}, adjust={self.adjust})' + + __str__ = __repr__ + + 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 diff --git a/tarantool/msgpack_ext/unpacker.py b/tarantool/msgpack_ext/unpacker.py index b303e18d..ff3bdcb8 100644 --- a/tarantool/msgpack_ext/unpacker.py +++ b/tarantool/msgpack_ext/unpacker.py @@ -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): diff --git a/test/suites/__init__.py b/test/suites/__init__.py index c5792bdd..7096cad9 100644 --- a/test/suites/__init__.py +++ b/test/suites/__init__.py @@ -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() diff --git a/test/suites/test_datetime.py b/test/suites/test_datetime.py index c952ec2a..a1fefae6 100644 --- a/test/suites/test_datetime.py +++ b/test/suites/test_datetime.py @@ -38,6 +38,16 @@ def setUpClass(self): box.schema.user.create('test', {password = 'test', if_not_exists = true}) box.schema.user.grant('test', 'read,write,execute', 'universe') + + local function add(arg1, arg2) + return arg1 + arg2 + end + rawset(_G, 'add', add) + + local function sub(arg1, arg2) + return arg1 - arg2 + end + rawset(_G, 'sub', sub) """) self.con = tarantool.Connection(self.srv.host, self.srv.args['primary'], @@ -321,6 +331,207 @@ def test_msgpack_decode_ambiguous_tzindex(self): MsgpackError, 'Failed to create datetime with ambiguous timezone "AET"', lambda: unpacker_ext_hook(4, case)) + + datetime_subtraction_cases = { + 'date': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Datetime(year=2010, month=2, day=1), + 'res': tarantool.Interval(year=-2, month=0, day=2), + }, + 'datetime': { + 'arg_1': tarantool.Datetime(year=2001, month=2, day=3, hour=1, minute=2, sec=30), + 'arg_2': tarantool.Datetime(year=2002, month=1, day=31, hour=3, minute=0, sec=20), + 'res': tarantool.Interval(year=-1, month=1, day=-28, hour=-2, minute=2, sec=10), + }, + 'datetime_with_nsec': { + 'arg_1': tarantool.Datetime(year=2001, month=2, day=3, hour=1, minute=2, + sec=30, nsec=10000000), + 'arg_2': tarantool.Datetime(year=2002, month=1, day=31, hour=3, minute=0, + sec=10, nsec=9876543), + 'res': tarantool.Interval(year=-1, month=1, day=-28, hour=-2, minute=2, + sec=20, nsec=123457), + }, + 'heterogenous': { + 'arg_1': tarantool.Datetime(year=2001, month=2, day=3, hour=1, minute=2), + 'arg_2': tarantool.Datetime(year=2001, month=2, day=3, sec=30, + nsec=9876543), + 'res': tarantool.Interval(hour=1, minute=2, sec=-30, nsec=-9876543), + }, + } + + def test_python_datetime_subtraction(self): + for name in self.datetime_subtraction_cases.keys(): + with self.subTest(msg=name): + case = self.datetime_subtraction_cases[name] + + self.assertEqual(case['arg_1'] - case['arg_2'], case['res']) + + @skip_or_run_datetime_test + def test_tarantool_datetime_subtraction(self): + for name in self.datetime_subtraction_cases.keys(): + with self.subTest(msg=name): + case = self.datetime_subtraction_cases[name] + + self.assertSequenceEqual(self.con.call('sub', case['arg_1'], case['arg_2']), + [case['res']]) + + + datetime_subtraction_different_timezones_case = { + 'arg_1': tarantool.Datetime(year=2001, month=2, day=3, tz='UTC'), + 'arg_2': tarantool.Datetime(year=2001, month=2, day=3, tz='MSK'), + 'res': tarantool.Interval(day=1, hour=-21), + } + + def test_python_datetime_subtraction_different_timezones(self): + case = self.datetime_subtraction_different_timezones_case + + self.assertEqual(case['arg_1'] - case['arg_2'], case['res']) + + @skip_or_run_datetime_test + @unittest.expectedFailure # See https://github.com/tarantool/tarantool/issues/7698 + def test_tarantool_datetime_subtraction_different_timezones(self): + case = self.datetime_subtraction_different_timezones_case + + self.assertSequenceEqual(self.con.call('sub', case['arg_1'], case['arg_2']), + [case['res']]) + + + interval_arithmetic_cases = { + 'year': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(year=1), + 'res_add': tarantool.Datetime(year=2009, month=2, day=3), + 'res_sub': tarantool.Datetime(year=2007, month=2, day=3), + }, + 'date': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(year=1, month=2, day=3), + 'res_add': tarantool.Datetime(year=2009, month=4, day=6), + 'res_sub': tarantool.Datetime(year=2006, month=11, day=30), + }, + 'date_days_overflow': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(year=1, month=2, day=30), + 'res_add': tarantool.Datetime(year=2009, month=5, day=3), + 'res_sub': tarantool.Datetime(year=2006, month=11, day=3), + }, + 'time': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(hour=1, minute=2, sec=3), + 'res_add': tarantool.Datetime(year=2008, month=2, day=3, hour=1, minute=2, sec=3), + 'res_sub': tarantool.Datetime(year=2008, month=2, day=2, hour=22, minute=57, sec=57), + }, + 'time_secs_overflow': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3), + 'arg_2': tarantool.Interval(hour=1, minute=2, sec=13003), + 'res_add': tarantool.Datetime(year=2008, month=2, day=3, hour=4, minute=38, sec=43), + 'res_sub': tarantool.Datetime(year=2008, month=2, day=2, hour=19, minute=21, sec=17), + }, + 'nsecs': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, sec=43), + 'arg_2': tarantool.Interval(nsec=10000023), + 'res_add': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, sec=43, + nsec=10000023), + 'res_sub': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, sec=42, + nsec=989999977), + }, + 'zero': { + 'arg_1': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, sec=43), + 'arg_2': tarantool.Interval(), + 'res_add': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, sec=43), + 'res_sub': tarantool.Datetime(year=2008, month=2, day=3, hour=3, minute=36, sec=43), + }, + 'month_non_last_day_none_adjust': { + 'arg_1': tarantool.Datetime(year=2009, month=1, day=30), + 'arg_2': tarantool.Interval(month=13, adjust=tarantool.IntervalAdjust.NONE), + 'res_add': tarantool.Datetime(year=2010, month=2, day=28), + 'res_sub': tarantool.Datetime(year=2007, month=12, day=30), + }, + 'month_non_last_day_none_adjust_negative': { + 'arg_1': tarantool.Datetime(year=2009, month=1, day=30), + 'arg_2': tarantool.Interval(month=11, adjust=tarantool.IntervalAdjust.NONE), + 'res_add': tarantool.Datetime(year=2009, month=12, day=30), + 'res_sub': tarantool.Datetime(year=2008, month=2, day=29), + }, + 'month_non_last_day_excess_adjust': { + 'arg_1': tarantool.Datetime(year=2009, month=1, day=30), + 'arg_2': tarantool.Interval(month=13, adjust=tarantool.IntervalAdjust.EXCESS), + 'res_add': tarantool.Datetime(year=2010, month=3, day=2), + 'res_sub': tarantool.Datetime(year=2007, month=12, day=30), + }, + 'month_non_last_day_excess_adjust_negative': { + 'arg_1': tarantool.Datetime(year=2009, month=1, day=30), + 'arg_2': tarantool.Interval(month=11, adjust=tarantool.IntervalAdjust.EXCESS), + 'res_add': tarantool.Datetime(year=2009, month=12, day=30), + 'res_sub': tarantool.Datetime(year=2008, month=3, day=1), + }, + 'month_non_last_day_last_adjust': { + 'arg_1': tarantool.Datetime(year=2009, month=3, day=30), + 'arg_2': tarantool.Interval(month=2, adjust=tarantool.IntervalAdjust.LAST), + 'res_add': tarantool.Datetime(year=2009, month=5, day=30), + 'res_sub': tarantool.Datetime(year=2009, month=1, day=30), + }, + 'month_overflow_last_day_last_adjust': { + 'arg_1': tarantool.Datetime(year=2009, month=2, day=28), + 'arg_2': tarantool.Interval(month=1, adjust=tarantool.IntervalAdjust.LAST), + 'res_add': tarantool.Datetime(year=2009, month=3, day=31), + 'res_sub': tarantool.Datetime(year=2009, month=1, day=31), + }, + } + + def test_python_datetime_addition(self): + for name in self.interval_arithmetic_cases.keys(): + with self.subTest(msg=name): + case = self.interval_arithmetic_cases[name] + + self.assertEqual(case['arg_1'] + case['arg_2'], case['res_add']) + + def test_python_datetime_subtraction(self): + for name in self.interval_arithmetic_cases.keys(): + with self.subTest(msg=name): + case = self.interval_arithmetic_cases[name] + + self.assertEqual(case['arg_1'] - case['arg_2'], case['res_sub']) + + @skip_or_run_datetime_test + def test_tarantool_datetime_addition(self): + for name in self.interval_arithmetic_cases.keys(): + with self.subTest(msg=name): + case = self.interval_arithmetic_cases[name] + + self.assertSequenceEqual(self.con.call('add', case['arg_1'], case['arg_2']), + [case['res_add']]) + + @skip_or_run_datetime_test + def test_tarantool_datetime_subtraction(self): + for name in self.interval_arithmetic_cases.keys(): + with self.subTest(msg=name): + case = self.interval_arithmetic_cases[name] + + self.assertSequenceEqual(self.con.call('sub', case['arg_1'], case['arg_2']), + [case['res_sub']]) + + + datetime_addition_winter_time_switch_case = { + 'arg_1': tarantool.Datetime(year=2008, month=1, day=1, hour=12, tz='Europe/Moscow'), + 'arg_2': tarantool.Interval(month=6), + 'res': tarantool.Datetime(year=2008, month=7, day=1, hour=12, tz='Europe/Moscow'), + } + + def test_python_datetime_addition_winter_time_switch(self): + case = self.datetime_addition_winter_time_switch_case + + self.assertEqual(case['arg_1'] + case['arg_2'], case['res']) + + @skip_or_run_datetime_test + @unittest.expectedFailure # See https://github.com/tarantool/tarantool/issues/7700 + def test_tarantool_datetime_addition_winter_time_switch(self): + case = self.datetime_addition_winter_time_switch_case + + self.assertSequenceEqual(self.con.call('add', case['arg_1'], case['arg_2']), + [case['res']]) + + @classmethod def tearDownClass(self): self.con.close() diff --git a/test/suites/test_interval.py b/test/suites/test_interval.py new file mode 100644 index 00000000..0913dacb --- /dev/null +++ b/test/suites/test_interval.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- + +from __future__ import print_function + +import re +import sys +import unittest +import msgpack +import warnings +import tarantool +import pandas +import pytz + +from tarantool.msgpack_ext.packer import default as packer_default +from tarantool.msgpack_ext.unpacker import ext_hook as unpacker_ext_hook + +from .lib.tarantool_server import TarantoolServer +from .lib.skip import skip_or_run_datetime_test +from tarantool.error import MsgpackError + +class TestSuite_Interval(unittest.TestCase): + @classmethod + def setUpClass(self): + print(' INTERVAL EXT TYPE '.center(70, '='), file=sys.stderr) + print('-' * 70, file=sys.stderr) + self.srv = TarantoolServer() + self.srv.script = 'test/suites/box.lua' + self.srv.start() + + self.adm = self.srv.admin + self.adm(r""" + _, datetime = pcall(require, 'datetime') + + box.schema.space.create('test') + box.space['test']:create_index('primary', { + type = 'tree', + parts = {1, 'string'}, + unique = true}) + + box.schema.user.create('test', {password = 'test', if_not_exists = true}) + box.schema.user.grant('test', 'read,write,execute', 'universe') + + local function add(arg1, arg2) + return arg1 + arg2 + end + rawset(_G, 'add', add) + + local function sub(arg1, arg2) + return arg1 - arg2 + end + rawset(_G, 'sub', sub) + """) + + self.con = tarantool.Connection(self.srv.host, self.srv.args['primary'], + user='test', password='test') + + def setUp(self): + # prevent a remote tarantool from clean our session + if self.srv.is_started(): + self.srv.touch_lock() + + self.adm("box.space['test']:truncate()") + + def test_Interval_bytes_init(self): + dt = tarantool.Interval(b'\x02\x00\x01\x08\x01') + + self.assertEqual(dt.year, 1) + self.assertEqual(dt.month, 0) + self.assertEqual(dt.day, 0) + self.assertEqual(dt.hour, 0) + self.assertEqual(dt.minute, 0) + self.assertEqual(dt.sec, 0) + self.assertEqual(dt.nsec, 0) + self.assertEqual(dt.adjust, tarantool.IntervalAdjust.NONE) + + def test_Interval_non_bytes_positional_init(self): + self.assertRaisesRegex( + ValueError, re.escape('data argument (first positional argument) ' + + 'expected to be a "bytes" instance'), + lambda: tarantool.Interval(1)) + + def test_Interval_bytes_init_ignore_other_fields(self): + dt = tarantool.Interval(b'\x02\x00\x01\x08\x01', + year=2, month=2, day=3, hour=1, minute=2, + sec=3000, nsec=10000000, + adjust=tarantool.IntervalAdjust.LAST) + + self.assertEqual(dt.year, 1) + self.assertEqual(dt.month, 0) + self.assertEqual(dt.day, 0) + self.assertEqual(dt.hour, 0) + self.assertEqual(dt.minute, 0) + self.assertEqual(dt.sec, 0) + self.assertEqual(dt.nsec, 0) + self.assertEqual(dt.adjust, tarantool.IntervalAdjust.NONE) + + def test_Interval_bytes_init_unknown_field(self): + self.assertRaisesRegex( + MsgpackError, 'Unknown interval field id 9', + lambda: tarantool.Interval(b'\x01\x09\xce\x00\x98\x96\x80')) + + def test_Interval_bytes_init_unknown_adjust(self): + self.assertRaisesRegex( + MsgpackError, '3 is not a valid Adjust', + lambda: tarantool.Interval(b'\x02\x07\xce\x00\x98\x96\x80\x08\x03')) + + + cases = { + 'year': { + 'python': tarantool.Interval(year=1), + 'msgpack': (b'\x02\x00\x01\x08\x01'), + 'tarantool': r"datetime.interval.new({year=1})", + }, + 'big_year': { + 'python': tarantool.Interval(year=1000), + 'msgpack': (b'\x02\x00\xcd\x03\xe8\x08\x01'), + 'tarantool': r"datetime.interval.new({year=1000})", + }, + 'date': { + 'python': tarantool.Interval(year=1, month=2, day=3), + 'msgpack': (b'\x04\x00\x01\x01\x02\x03\x03\x08\x01'), + 'tarantool': r"datetime.interval.new({year=1, month=2, day=3})", + }, + 'big_month_date': { + 'python': tarantool.Interval(year=1, month=100000, day=3), + 'msgpack': (b'\x04\x00\x01\x01\xce\x00\x01\x86\xa0\x03\x03\x08\x01'), + 'tarantool': r"datetime.interval.new({year=1, month=100000, day=3})", + }, + 'time': { + 'python': tarantool.Interval(hour=1, minute=2, sec=3), + 'msgpack': (b'\x04\x04\x01\x05\x02\x06\x03\x08\x01'), + 'tarantool': r"datetime.interval.new({hour=1, min=2, sec=3})", + }, + 'big_seconds_time': { + 'python': tarantool.Interval(hour=1, minute=2, sec=3000), + 'msgpack': (b'\x04\x04\x01\x05\x02\x06\xcd\x0b\xb8\x08\x01'), + 'tarantool': r"datetime.interval.new({hour=1, min=2, sec=3000})", + }, + 'datetime': { + 'python': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, sec=3000), + 'msgpack': (b'\x07\x00\x01\x01\x02\x03\x03\x04\x01\x05\x02\x06\xcd\x0b\xb8\x08\x01'), + 'tarantool': r"datetime.interval.new({year=1, month=2, day=3, hour=1, min=2, sec=3000})", + }, + 'nanoseconds': { + 'python': tarantool.Interval(nsec=10000000), + 'msgpack': (b'\x02\x07\xce\x00\x98\x96\x80\x08\x01'), + 'tarantool': r"datetime.interval.new({nsec=10000000})", + }, + 'datetime_with_nanoseconds': { + 'python': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, + sec=3000, nsec=10000000), + 'msgpack': (b'\x08\x00\x01\x01\x02\x03\x03\x04\x01\x05\x02\x06\xcd\x0b\xb8\x07\xce' + + b'\x00\x98\x96\x80\x08\x01'), + 'tarantool': r"datetime.interval.new({year=1, month=2, day=3, hour=1, " + + r"min=2, sec=3000, nsec=10000000})", + }, + 'datetime_none_adjust': { + 'python': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, + sec=3000, nsec=10000000, + adjust=tarantool.IntervalAdjust.NONE), + 'msgpack': (b'\x08\x00\x01\x01\x02\x03\x03\x04\x01\x05\x02\x06\xcd\x0b\xb8\x07\xce' + + b'\x00\x98\x96\x80\x08\x01'), + 'tarantool': r"datetime.interval.new({year=1, month=2, day=3, hour=1, " + + r"min=2, sec=3000, nsec=10000000, adjust='none'})", + }, + 'datetime_excess_adjust': { + 'python': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, + sec=3000, nsec=10000000, + adjust=tarantool.IntervalAdjust.EXCESS), + 'msgpack': (b'\x07\x00\x01\x01\x02\x03\x03\x04\x01\x05\x02\x06\xcd\x0b\xb8\x07\xce' + + b'\x00\x98\x96\x80'), + 'tarantool': r"datetime.interval.new({year=1, month=2, day=3, hour=1, " + + r"min=2, sec=3000, nsec=10000000, adjust='excess'})", + }, + 'datetime_last_adjust': { + 'python': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, + sec=3000, nsec=10000000, + adjust=tarantool.IntervalAdjust.LAST), + 'msgpack': (b'\x08\x00\x01\x01\x02\x03\x03\x04\x01\x05\x02\x06\xcd\x0b\xb8\x07\xce' + + b'\x00\x98\x96\x80\x08\x02'), + 'tarantool': r"datetime.interval.new({year=1, month=2, day=3, hour=1, " + + r"min=2, sec=3000, nsec=10000000, adjust='last'})", + }, + 'all_zeroes': { + 'python': tarantool.Interval(adjust=tarantool.IntervalAdjust.EXCESS), + 'msgpack': (b'\x00'), + 'tarantool': r"datetime.interval.new({adjust='excess'})", + }, + } + + def test_msgpack_decode(self): + for name in self.cases.keys(): + with self.subTest(msg=name): + case = self.cases[name] + + self.assertEqual(unpacker_ext_hook(6, case['msgpack']), + case['python']) + + @skip_or_run_datetime_test + def test_tarantool_decode(self): + for name in self.cases.keys(): + with self.subTest(msg=name): + case = self.cases[name] + + self.adm(f"box.space['test']:replace{{'{name}', {case['tarantool']}, 'field'}}") + + self.assertSequenceEqual(self.con.select('test', name), + [[name, case['python'], 'field']]) + + def test_msgpack_encode(self): + for name in self.cases.keys(): + with self.subTest(msg=name): + case = self.cases[name] + + self.assertEqual(packer_default(case['python']), + msgpack.ExtType(code=6, data=case['msgpack'])) + + @skip_or_run_datetime_test + def test_tarantool_encode(self): + for name in self.cases.keys(): + with self.subTest(msg=name): + case = self.cases[name] + + self.con.insert('test', [name, case['python'], 'field']) + + lua_eval = f""" + local interval = {case['tarantool']} + + local tuple = box.space['test']:get('{name}') + assert(tuple ~= nil) + + if tuple[2] == interval then + return true + else + return nil, ('%s is not equal to expected %s'):format( + tostring(tuple[2]), tostring(interval)) + end + """ + + self.assertSequenceEqual(self.adm(lua_eval), [True]) + + + def test_unknown_field_decode(self): + case = b'\x01\x09\xce\x00\x98\x96\x80' + self.assertRaisesRegex( + MsgpackError, 'Unknown interval field id 9', + lambda: unpacker_ext_hook(6, case)) + + def test_unknown_adjust_decode(self): + case = b'\x02\x07\xce\x00\x98\x96\x80\x08\x03' + self.assertRaisesRegex( + MsgpackError, '3 is not a valid Adjust', + lambda: unpacker_ext_hook(6, case)) + + + arithmetic_cases = { + 'year': { + 'arg_1': tarantool.Interval(year=2), + 'arg_2': tarantool.Interval(year=1), + 'res_add': tarantool.Interval(year=3), + 'res_sub': tarantool.Interval(year=1), + }, + 'date': { + 'arg_1': tarantool.Interval(year=1, month=2, day=3), + 'arg_2': tarantool.Interval(year=3, month=2, day=1), + 'res_add': tarantool.Interval(year=4, month=4, day=4), + 'res_sub': tarantool.Interval(year=-2, month=0, day=2), + }, + 'time': { + 'arg_1': tarantool.Interval(hour=10, minute=20, sec=30), + 'arg_2': tarantool.Interval(hour=2, minute=15, sec=50), + 'res_add': tarantool.Interval(hour=12, minute=35, sec=80), + 'res_sub': tarantool.Interval(hour=8, minute=5, sec=-20), + }, + 'datetime': { + 'arg_1': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, sec=3000), + 'arg_2': tarantool.Interval(year=2, month=1, day=31, hour=-3, minute=0, sec=-2000), + 'res_add': tarantool.Interval(year=3, month=3, day=34, hour=-2, minute=2, sec=1000), + 'res_sub': tarantool.Interval(year=-1, month=1, day=-28, hour=4, minute=2, sec=5000), + }, + 'datetime_with_nsec': { + 'arg_1': tarantool.Interval(year=1, month=2, day=3, hour=1, minute=2, + sec=3000, nsec=10000000), + 'arg_2': tarantool.Interval(year=2, month=1, day=31, hour=-3, minute=0, + sec=1000, nsec=9876543), + 'res_add': tarantool.Interval(year=3, month=3, day=34, hour=-2, minute=2, + sec=4000, nsec=19876543), + 'res_sub': tarantool.Interval(year=-1, month=1, day=-28, hour=4, minute=2, + sec=2000, nsec=123457), + }, + 'heterogenous': { + 'arg_1': tarantool.Interval(year=1, month=2, day=3), + 'arg_2': tarantool.Interval(sec=3000, nsec=9876543), + 'res_add': tarantool.Interval(year=1, month=2, day=3, + sec=3000, nsec=9876543), + 'res_sub': tarantool.Interval(year=1, month=2, day=3, + sec=-3000, nsec=-9876543), + }, + 'same_adjust': { + 'arg_1': tarantool.Interval(year=2, adjust=tarantool.IntervalAdjust.LAST), + 'arg_2': tarantool.Interval(year=1, adjust=tarantool.IntervalAdjust.LAST), + 'res_add': tarantool.Interval(year=3, adjust=tarantool.IntervalAdjust.LAST), + 'res_sub': tarantool.Interval(year=1, adjust=tarantool.IntervalAdjust.LAST), + }, + 'different_adjust': { + 'arg_1': tarantool.Interval(year=2, adjust=tarantool.IntervalAdjust.LAST), + 'arg_2': tarantool.Interval(year=1, adjust=tarantool.IntervalAdjust.EXCESS), + 'res_add': tarantool.Interval(year=3, adjust=tarantool.IntervalAdjust.LAST), + 'res_sub': tarantool.Interval(year=1, adjust=tarantool.IntervalAdjust.LAST), + }, + } + + def test_python_interval_addition(self): + for name in self.arithmetic_cases.keys(): + with self.subTest(msg=name): + case = self.arithmetic_cases[name] + + self.assertEqual(case['arg_1'] + case['arg_2'], case['res_add']) + + def test_python_interval_subtraction(self): + for name in self.arithmetic_cases.keys(): + with self.subTest(msg=name): + case = self.arithmetic_cases[name] + + self.assertEqual(case['arg_1'] - case['arg_2'], case['res_sub']) + + @skip_or_run_datetime_test + def test_tarantool_interval_addition(self): + for name in self.arithmetic_cases.keys(): + with self.subTest(msg=name): + case = self.arithmetic_cases[name] + + self.assertSequenceEqual(self.con.call('add', case['arg_1'], case['arg_2']), + [case['res_add']]) + + @skip_or_run_datetime_test + def test_tarantool_interval_subtraction(self): + for name in self.arithmetic_cases.keys(): + with self.subTest(msg=name): + case = self.arithmetic_cases[name] + + self.assertSequenceEqual(self.con.call('sub', case['arg_1'], case['arg_2']), + [case['res_sub']]) + + + @classmethod + def tearDownClass(self): + self.con.close() + self.srv.stop() + self.srv.clean()