diff --git a/.readthedocs.yaml b/.readthedocs.yaml index bc990993..7561bbea 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,7 +1,7 @@ version: 2 build: - os: "ubuntu-20.04" + os: "ubuntu-24.04" tools: python: "3.10" jobs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8edd47e7..f507f1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Drop Python 3.6 support (PR #327). +- Support the maximum decimal precision for tarantool 3.5 and above (PR #342). + +### Fixed +- Set upper bound for version of setuptools (PR #342). ## 1.2.0 - 2024-03-27 diff --git a/requirements-test.txt b/requirements-test.txt index 0f2ad987..ba964e70 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -8,4 +8,4 @@ flake8 == 6.1.0 ; python_version >= '3.8' flake8 == 5.0.4 ; python_version < '3.8' codespell == 2.3.0 ; python_version >= '3.8' codespell == 2.2.5 ; python_version < '3.8' -setuptools >= 75.3.2 +setuptools >= 75.3.2, < 82.0.0 diff --git a/tarantool/msgpack_ext/decimal.py b/tarantool/msgpack_ext/decimal.py index f1654784..c408266a 100644 --- a/tarantool/msgpack_ext/decimal.py +++ b/tarantool/msgpack_ext/decimal.py @@ -65,7 +65,18 @@ `decimal`_ type id. """ -TARANTOOL_DECIMAL_MAX_DIGITS = 38 + +def decimal_max_digits_errmsg(max_digits): + """ + Build an error / warning message for max decimal precision. + + :param max_digits: Max supported precision. + :type max_digits: :obj:`int` + + :rtype: :obj:`str` + """ + + return f'Tarantool decimal supports a maximum of {max_digits} digits.' def get_mp_sign(sign): @@ -114,7 +125,7 @@ def add_mp_digit(digit, bytes_reverted, digit_count): bytes_reverted.append(digit) -def check_valid_tarantool_decimal(str_repr, scale, first_digit_ind): +def check_valid_tarantool_decimal(str_repr, scale, first_digit_ind, max_digits): """ Decimal numbers have 38 digits of precision, that is, the total number of digits before and after the decimal point can be 38. If @@ -170,6 +181,9 @@ def check_valid_tarantool_decimal(str_repr, scale, first_digit_ind): representation. :type first_digit_ind: :obj:`int` + :param max_digits: Max supported precision. + :type max_digits: :obj:`int` + :return: ``True``, if decimal can be encoded to Tarantool decimal without precision loss. ``False`` otherwise. :rtype: :obj:`bool` @@ -184,26 +198,26 @@ def check_valid_tarantool_decimal(str_repr, scale, first_digit_ind): else: digit_count = len(str_repr) - first_digit_ind - if digit_count <= TARANTOOL_DECIMAL_MAX_DIGITS: + if digit_count <= max_digits: return True - if (digit_count - scale) > TARANTOOL_DECIMAL_MAX_DIGITS: - raise MsgpackError('Decimal cannot be encoded: Tarantool decimal ' - 'supports a maximum of 38 digits.') + if (digit_count - scale) > max_digits: + raise MsgpackError('Decimal cannot be encoded: ' + + decimal_max_digits_errmsg(max_digits)) starts_with_zero = str_repr[first_digit_ind] == '0' - if (digit_count > TARANTOOL_DECIMAL_MAX_DIGITS + 1) or \ - (digit_count == TARANTOOL_DECIMAL_MAX_DIGITS + 1 and not starts_with_zero): + if (digit_count > max_digits + 1) or \ + (digit_count == max_digits + 1 and not starts_with_zero): warn('Decimal encoded with loss of precision: ' - 'Tarantool decimal supports a maximum of 38 digits.', + + decimal_max_digits_errmsg(max_digits), MsgpackWarning) return False return True -def strip_decimal_str(str_repr, scale, first_digit_ind): +def strip_decimal_str(str_repr, scale, first_digit_ind, max_digits): """ Strip decimal digits after the decimal point if decimal cannot be represented as Tarantool decimal without precision loss. @@ -218,12 +232,15 @@ def strip_decimal_str(str_repr, scale, first_digit_ind): representation. :type first_digit_ind: :obj:`int` + :param max_digits: Max supported precision. + :type max_digits: :obj:`int` + :meta private: """ assert scale > 0 # Strip extra bytes - str_repr = str_repr[:TARANTOOL_DECIMAL_MAX_DIGITS + 1 + first_digit_ind] + str_repr = str_repr[:max_digits + 1 + first_digit_ind] str_repr = str_repr.rstrip('0') str_repr = str_repr.rstrip('.') @@ -231,13 +248,16 @@ def strip_decimal_str(str_repr, scale, first_digit_ind): return str_repr -def encode(obj, _): +def encode(obj, _packer, max_digits): """ Encode a decimal object. :param obj: Decimal to encode. :type obj: :obj:`decimal.Decimal` + :param max_digits: Max supported precision. + :type max_digits: :obj:`int` + :return: Encoded decimal. :rtype: :obj:`bytes` @@ -262,8 +282,8 @@ def encode(obj, _): sign = '+' first_digit_ind = 0 - if not check_valid_tarantool_decimal(str_repr, scale, first_digit_ind): - str_repr = strip_decimal_str(str_repr, scale, first_digit_ind) + if not check_valid_tarantool_decimal(str_repr, scale, first_digit_ind, max_digits): + str_repr = strip_decimal_str(str_repr, scale, first_digit_ind, max_digits) bytes_reverted.append(get_mp_sign(sign)) diff --git a/tarantool/msgpack_ext/extensions.py b/tarantool/msgpack_ext/extensions.py new file mode 100644 index 00000000..8a644bb5 --- /dev/null +++ b/tarantool/msgpack_ext/extensions.py @@ -0,0 +1,76 @@ +""" +This module provides Tarantool `extension`_ types handlers. + +.. _extension: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/ +""" +# encoding: utf-8 +import functools +from collections import namedtuple + +from decimal import Decimal +from uuid import UUID +from tarantool.types import BoxError +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.error as ext_error +import tarantool.msgpack_ext.datetime as ext_datetime +import tarantool.msgpack_ext.interval as ext_interval +from tarantool.utils import version_id + + +class Extension(namedtuple("Extension", "decode encode type")): + """ + MessagePack extension type handlers. + + :ivar decode: Extension type decoder. + :vartype decode: :obj:`callable` + + :ivar encode: Extension type encoder. + :vartype encode: :obj:`callable` + + :ivar type: Python type represented by the extension. + :vartype type: :obj:`type` + """ + + +TARANTOOL_DECIMAL_MAX_DIGITS = 38 +TARANTOOL_DECIMAL_MAX_DIGITS_V35 = 76 + + +def init_msgpack_extensions(tarantool_version=None): + """ + Initialize MessagePack extension type handlers. + + :param tarantool_version: Tarantool version identifier. + :type tarantool_version: :obj:`int`, optional + + :return: Mapping from Tarantool extension type id to its handlers. + :rtype: :obj:`dict` + """ + + max_digits = TARANTOOL_DECIMAL_MAX_DIGITS + if tarantool_version is not None and tarantool_version >= version_id(3, 5, 0): + max_digits = TARANTOOL_DECIMAL_MAX_DIGITS_V35 + + return { + ext_decimal.EXT_ID: Extension( + ext_decimal.decode, + functools.partial(ext_decimal.encode, max_digits=max_digits), + Decimal, + ), + ext_uuid.EXT_ID: Extension( + ext_uuid.decode, ext_uuid.encode, UUID, + ), + ext_error.EXT_ID: Extension( + ext_error.decode, ext_error.encode, BoxError, + ), + ext_datetime.EXT_ID: Extension( + ext_datetime.decode, ext_datetime.encode, Datetime, + ), + ext_interval.EXT_ID: Extension( + ext_interval.decode, ext_interval.encode, Interval, + ), + } diff --git a/tarantool/msgpack_ext/packer.py b/tarantool/msgpack_ext/packer.py index e2c03b8b..97b8c409 100644 --- a/tarantool/msgpack_ext/packer.py +++ b/tarantool/msgpack_ext/packer.py @@ -3,32 +3,13 @@ .. _extension: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/ """ -# pylint: disable=duplicate-code -from decimal import Decimal -from uuid import UUID from msgpack import ExtType -from tarantool.types import BoxError -from tarantool.msgpack_ext.types.datetime import Datetime -from tarantool.msgpack_ext.types.interval import Interval +from tarantool.msgpack_ext.extensions import init_msgpack_extensions -import tarantool.msgpack_ext.decimal as ext_decimal -import tarantool.msgpack_ext.uuid as ext_uuid -import tarantool.msgpack_ext.error as ext_error -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': BoxError, 'ext': ext_error}, - {'type': Datetime, 'ext': ext_datetime}, - {'type': Interval, 'ext': ext_interval}, -] - - -def default(obj, packer=None): +def default(obj, packer=None, tarantool_version=None): """ :class:`msgpack.Packer` encoder. @@ -41,13 +22,16 @@ def default(obj, packer=None): (like dictionary in extended error payload) :type packer: :class:`msgpack.Packer`, optional + :param tarantool_version: Tarantool version identifier. + :type tarantool_version: :obj:`int`, optional + :return: Encoded value. :rtype: :class:`msgpack.ExtType` :raise: :exc:`~TypeError` """ - for encoder in encoders: - if isinstance(obj, encoder['type']): - return ExtType(encoder['ext'].EXT_ID, encoder['ext'].encode(obj, packer)) + for ext_id, ext in init_msgpack_extensions(tarantool_version).items(): + if isinstance(obj, ext.type): + return ExtType(ext_id, ext.encode(obj, packer)) raise TypeError(f"Unknown type: {repr(obj)}") diff --git a/tarantool/msgpack_ext/unpacker.py b/tarantool/msgpack_ext/unpacker.py index 985653d5..33e0ffe6 100644 --- a/tarantool/msgpack_ext/unpacker.py +++ b/tarantool/msgpack_ext/unpacker.py @@ -3,24 +3,10 @@ .. _extension: https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/ """ -# pylint: disable=duplicate-code +from tarantool.msgpack_ext.extensions import init_msgpack_extensions -import tarantool.msgpack_ext.decimal as ext_decimal -import tarantool.msgpack_ext.uuid as ext_uuid -import tarantool.msgpack_ext.error as ext_error -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_error.EXT_ID: ext_error.decode, - ext_datetime.EXT_ID: ext_datetime.decode, - ext_interval.EXT_ID: ext_interval.decode, -} - - -def ext_hook(code, data, unpacker=None): +def ext_hook(code, data, unpacker=None, tarantool_version=None): """ :class:`msgpack.Unpacker` decoder. @@ -34,6 +20,9 @@ def ext_hook(code, data, unpacker=None): (like dictionary in extended error payload) :type unpacker: :class:`msgpack.Unpacker`, optional + :param tarantool_version: Tarantool version identifier. + :type tarantool_version: :obj:`int`, optional + :return: Decoded value. :rtype: :class:`decimal.Decimal` or :class:`uuid.UUID` or or :class:`tarantool.BoxError` or :class:`tarantool.Datetime` @@ -41,7 +30,8 @@ def ext_hook(code, data, unpacker=None): :raise: :exc:`NotImplementedError` """ + ext = init_msgpack_extensions(tarantool_version) - if code in decoders: - return decoders[code](data, unpacker) + if code in ext: + return ext[code].decode(data, unpacker) raise NotImplementedError(f"Unknown msgpack extension type code {code}") diff --git a/tarantool/request.py b/tarantool/request.py index 95164b79..cf965736 100644 --- a/tarantool/request.py +++ b/tarantool/request.py @@ -112,7 +112,7 @@ def packer_factory(conn): # inside extension type packers. def default(obj): packer_no_ext = msgpack.Packer(**packer_kwargs) - return packer_default(obj, packer_no_ext) + return packer_default(obj, packer_no_ext, conn.version_id) packer_kwargs['default'] = default return msgpack.Packer(**packer_kwargs) diff --git a/test/suites/lib/skip.py b/test/suites/lib/skip.py index 625caf6a..baf24bee 100644 --- a/test/suites/lib/skip.py +++ b/test/suites/lib/skip.py @@ -80,6 +80,44 @@ def skip_or_run_test_tarantool_call(self, required_tt_version, msg): skip_or_run_test_tarantool_impl(self, required_tt_version, msg) +def skip_or_run_test_tarantool_lt_impl(self, unsupported_tt_version, msg): + """ + Helper to skip or run tests depending on the Tarantool + version. + + Skip tests when the Tarantool version is greater than or equal + to the provided version. + """ + fetch_tarantool_version(self) + + unsupported_version = pkg_resources.parse_version(unsupported_tt_version) + + if self.tnt_version >= unsupported_version: + self.skipTest(f'Tarantool {self.tnt_version} {msg}') + + +def skip_or_run_test_tarantool_lt(func, unsupported_tt_version, msg): + """ + Decorator to skip or run tests depending on the tarantool + version. + + Skip tests when the Tarantool version is greater than or equal + to the provided version. + """ + + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + if func.__name__ == 'setUp': + func(self, *args, **kwargs) + + skip_or_run_test_tarantool_lt_impl(self, unsupported_tt_version, msg) + + if func.__name__ != 'setUp': + func(self, *args, **kwargs) + + return wrapper + + def skip_or_run_test_pcall_require(func, required_tt_module, msg): """ Decorator to skip or run tests depending on tarantool @@ -179,6 +217,30 @@ def skip_or_run_decimal_test(func): 'does not support decimal type') +def skip_or_run_decimal_before_3_5_test(func): + """ + Decorator to skip or run decimal-related tests depending on + the tarantool version. + + Skips tests on Tarantool 3.5.0 and newer. + """ + + return skip_or_run_test_tarantool_lt(func, '3.5.0', + 'supports 76-digit decimals') + + +def skip_or_run_decimal_3_5_test(func): + """ + Decorator to skip or run decimal-related tests depending on + the tarantool version. + + Skips tests on Tarantool older than 3.5.0. + """ + + return skip_or_run_test_tarantool(func, '3.5.0', + 'does not support 76-digit decimals') + + def skip_or_run_uuid_test(func): """ Decorator to skip or run UUID-related tests depending on diff --git a/test/suites/test_connection.py b/test/suites/test_connection.py index 4402f0b0..bc66a59f 100644 --- a/test/suites/test_connection.py +++ b/test/suites/test_connection.py @@ -82,7 +82,7 @@ def test_custom_packer(self): def my_ext_type_encoder(obj): if isinstance(obj, decimal.Decimal): obj = obj + 1 - return msgpack.ExtType(ext_decimal.EXT_ID, ext_decimal.encode(obj, None)) + return msgpack.ExtType(ext_decimal.EXT_ID, ext_decimal.encode(obj, None, 35)) raise TypeError(f"Unknown type: {repr(obj)}") def my_packer_factory(_): diff --git a/test/suites/test_decimal.py b/test/suites/test_decimal.py index 2875a7da..8a4044ed 100644 --- a/test/suites/test_decimal.py +++ b/test/suites/test_decimal.py @@ -13,13 +13,20 @@ from tarantool.error import MsgpackError, MsgpackWarning from tarantool.msgpack_ext.packer import default as packer_default from tarantool.msgpack_ext.unpacker import ext_hook as unpacker_ext_hook +from tarantool.utils import version_id from .lib.tarantool_server import TarantoolServer -from .lib.skip import skip_or_run_decimal_test +from .lib.skip import ( + skip_or_run_decimal_test, + skip_or_run_decimal_before_3_5_test, + skip_or_run_decimal_3_5_test, +) from .utils import assert_admin_success class TestSuiteDecimal(unittest.TestCase): + decimal_v35_version_id = version_id(3, 5, 0) + @classmethod def setUpClass(cls): print(' DECIMAL EXT TYPE '.center(70, '='), file=sys.stderr) @@ -70,6 +77,22 @@ def setUp(self): """) assert_admin_success(resp) + def assert_decimal_tuple_equals(self, name, tarantool_decimal): + lua_eval = f""" + local tuple = box.space['test']:get('{name}') + assert(tuple ~= nil) + + local dec = {tarantool_decimal} + if tuple[2] == dec then + return true + else + return nil, ('%s is not equal to expected %s'):format( + tostring(tuple[2]), tostring(dec)) + end + """ + + self.assertSequenceEqual(self.con.eval(lua_eval), [True]) + valid_cases = { 'simple_decimal_1': { 'python': decimal.Decimal('0.7'), @@ -172,58 +195,58 @@ def setUp(self): 'tarantool': "decimal.new('0.001')", }, 'decimal_limits_1': { - 'python': decimal.Decimal('11111111111111111111111111111111111111'), + 'python': decimal.Decimal('1' * 38), 'msgpack': (b'\x00\x01\x11\x11\x11\x11\x11\x11\x11\x11\x11' b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x1c'), - 'tarantool': "decimal.new('11111111111111111111111111111111111111')", + 'tarantool': "decimal.new('" + '1' * 38 + "')", }, 'decimal_limits_2': { - 'python': decimal.Decimal('-11111111111111111111111111111111111111'), + 'python': decimal.Decimal('-' + '1' * 38), 'msgpack': (b'\x00\x01\x11\x11\x11\x11\x11\x11\x11\x11\x11' b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x1d'), - 'tarantool': "decimal.new('-11111111111111111111111111111111111111')", + 'tarantool': "decimal.new('-" + '1' * 38 + "')", }, 'decimal_limits_3': { - 'python': decimal.Decimal('0.0000000000000000000000000000000000001'), + 'python': decimal.Decimal('0.' + '0' * 36 + '1'), 'msgpack': (b'\x25\x1c'), - 'tarantool': "decimal.new('0.0000000000000000000000000000000000001')", + 'tarantool': "decimal.new('0." + '0' * 36 + "1')", }, 'decimal_limits_4': { - 'python': decimal.Decimal('-0.0000000000000000000000000000000000001'), + 'python': decimal.Decimal('-0.' + '0' * 36 + '1'), 'msgpack': (b'\x25\x1d'), - 'tarantool': "decimal.new('-0.0000000000000000000000000000000000001')", + 'tarantool': "decimal.new('-0." + '0' * 36 + "1')", }, 'decimal_limits_5': { - 'python': decimal.Decimal('0.00000000000000000000000000000000000001'), + 'python': decimal.Decimal('0.' + '0' * 37 + '1'), 'msgpack': (b'\x26\x1c'), - 'tarantool': "decimal.new('0.00000000000000000000000000000000000001')", + 'tarantool': "decimal.new('0." + '0' * 37 + "1')", }, 'decimal_limits_6': { - 'python': decimal.Decimal('-0.00000000000000000000000000000000000001'), + 'python': decimal.Decimal('-0.' + '0' * 37 + '1'), 'msgpack': (b'\x26\x1d'), - 'tarantool': "decimal.new('-0.00000000000000000000000000000000000001')", + 'tarantool': "decimal.new('-0." + '0' * 37 + "1')", }, 'decimal_limits_7': { - 'python': decimal.Decimal('0.00000000000000000000000000000000000009'), + 'python': decimal.Decimal('0.' + '0' * 37 + '9'), 'msgpack': (b'\x26\x9c'), - 'tarantool': "decimal.new('0.00000000000000000000000000000000000009')", + 'tarantool': "decimal.new('0." + '0' * 37 + "9')", }, 'decimal_limits_8': { - 'python': decimal.Decimal('0.00000000000000000000000000000000000009'), + 'python': decimal.Decimal('0.' + '0' * 37 + '9'), 'msgpack': (b'\x26\x9c'), - 'tarantool': "decimal.new('0.00000000000000000000000000000000000009')", + 'tarantool': "decimal.new('0." + '0' * 37 + "9')", }, 'decimal_limits_9': { - 'python': decimal.Decimal('99999999999999999999999999999999999999'), + 'python': decimal.Decimal('9' * 38), 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x9c'), - 'tarantool': "decimal.new('99999999999999999999999999999999999999')", + 'tarantool': "decimal.new('" + '9' * 38 + "')", }, 'decimal_limits_10': { - 'python': decimal.Decimal('-99999999999999999999999999999999999999'), + 'python': decimal.Decimal('-' + '9' * 38), 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x9d'), - 'tarantool': "decimal.new('-99999999999999999999999999999999999999')", + 'tarantool': "decimal.new('-" + '9' * 38 + "')", }, 'decimal_limits_11': { 'python': decimal.Decimal('1234567891234567890.0987654321987654321'), @@ -272,12 +295,112 @@ def setUp(self): }, } + v35_valid_cases = { + 'decimal_v35_limits_1': { + 'python': decimal.Decimal('1' * 76), + 'msgpack': (b'\x00\x01\x11\x11\x11\x11\x11\x11\x11\x11\x11' + b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11' + b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11' + b'\x11\x11\x11\x11\x11\x11\x1c'), + 'tarantool': "decimal.new('" + '1' * 76 + "')", + }, + 'decimal_v35_limits_2': { + 'python': decimal.Decimal('-' + '1' * 76), + 'msgpack': (b'\x00\x01\x11\x11\x11\x11\x11\x11\x11\x11\x11' + b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11' + b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11' + b'\x11\x11\x11\x11\x11\x11\x1d'), + 'tarantool': "decimal.new('-" + '1' * 76 + "')", + }, + 'decimal_v35_limits_3': { + 'python': decimal.Decimal('9' * 76), + 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x9c'), + 'tarantool': "decimal.new('" + '9' * 76 + "')", + }, + 'decimal_v35_limits_4': { + 'python': decimal.Decimal('-' + '9' * 76), + 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x9d'), + 'tarantool': "decimal.new('-" + '9' * 76 + "')", + }, + 'decimal_v35_limits_5': { + 'python': decimal.Decimal('0.' + '0' * 75 + '1'), + 'msgpack': (b'\x4c\x1c'), + 'tarantool': "decimal.new('0." + '0' * 75 + "1')", + }, + 'decimal_v35_limits_6': { + 'python': decimal.Decimal('-0.' + '0' * 75 + '1'), + 'msgpack': (b'\x4c\x1d'), + 'tarantool': "decimal.new('-0." + '0' * 75 + "1')", + }, + 'decimal_v35_limits_7': { + 'python': decimal.Decimal('9' * 38 + '.' + '1' * 38), + 'msgpack': (b'\x26\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x91\x11' + b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11' + b'\x11\x11\x11\x11\x11\x11\x1c'), + 'tarantool': "decimal.new('" + '9' * 38 + "." + '1' * 38 + "')", + }, + 'decimal_v35_limits_8': { + 'python': decimal.Decimal('-' + '9' * 38 + '.' + '1' * 38), + 'msgpack': (b'\x26\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x91\x11' + b'\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11' + b'\x11\x11\x11\x11\x11\x11\x1d'), + 'tarantool': "decimal.new('-" + '9' * 38 + "." + '1' * 38 + "')", + }, + 'decimal_v35_exponent_1': { + 'python': decimal.Decimal('1e75'), + 'msgpack': (b'\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x0c'), + 'tarantool': "decimal.new('1e75')", + }, + 'decimal_v35_exponent_2': { + 'python': decimal.Decimal('-1e75'), + 'msgpack': (b'\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x0d'), + 'tarantool': "decimal.new('-1e75')", + }, + 'decimal_v35_exponent_3': { + 'python': decimal.Decimal('1e-75'), + 'msgpack': (b'\x4b\x1c'), + 'tarantool': "decimal.new('1e-75')", + }, + 'decimal_v35_exponent_4': { + 'python': decimal.Decimal('1.2345e75'), + 'msgpack': (b'\x00\x01\x23\x45\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00' + b'\x00\x00\x00\x00\x00\x00\x0c'), + 'tarantool': "decimal.new('1.2345e75')", + }, + 'decimal_v35_exponent_5': { + 'python': decimal.Decimal('1.2345e-71'), + 'msgpack': (b'\x4b\x12\x34\x5c'), + 'tarantool': "decimal.new('1.2345e-71')", + }, + } + def test_msgpack_decode(self): for name, case in self.valid_cases.items(): with self.subTest(msg=name): self.assertEqual(unpacker_ext_hook(1, case['msgpack']), case['python']) + for name, case in self.v35_valid_cases.items(): + with self.subTest(msg=name): + self.assertEqual(unpacker_ext_hook(1, case['msgpack']), + case['python']) + @skip_or_run_decimal_test def test_tarantool_decode(self): for name, case in self.valid_cases.items(): @@ -288,64 +411,92 @@ def test_tarantool_decode(self): self.con.select('test', name), [[name, case['python']]]) + @skip_or_run_decimal_test + @skip_or_run_decimal_3_5_test + def test_tarantool_decode_v35(self): + for name, case in self.v35_valid_cases.items(): + with self.subTest(msg=name): + self.adm(f"box.space['test']:replace{{'{name}', {case['tarantool']}}}") + + self.assertSequenceEqual( + self.con.select('test', name), + [[name, case['python']]]) + def test_msgpack_encode(self): for name, case in self.valid_cases.items(): with self.subTest(msg=name): self.assertEqual(packer_default(case['python']), msgpack.ExtType(code=1, data=case['msgpack'])) + def test_msgpack_encode_v35(self): + for name, case in self.v35_valid_cases.items(): + with self.subTest(msg=name): + self.assertEqual( + packer_default(case['python'], + tarantool_version=self.decimal_v35_version_id), + msgpack.ExtType(code=1, data=case['msgpack'])) + @skip_or_run_decimal_test def test_tarantool_encode(self): for name, case in self.valid_cases.items(): with self.subTest(msg=name): self.con.insert('test', [name, case['python']]) + self.assert_decimal_tuple_equals(name, case['tarantool']) - lua_eval = f""" - local tuple = box.space['test']:get('{name}') - assert(tuple ~= nil) - - local dec = {case['tarantool']} - if tuple[2] == dec then - return true - else - return nil, ('%s is not equal to expected %s'):format( - tostring(tuple[2]), tostring(dec)) - end - """ - - self.assertSequenceEqual(self.con.eval(lua_eval), [True]) + @skip_or_run_decimal_test + @skip_or_run_decimal_3_5_test + def test_tarantool_encode_v35(self): + for name, case in self.v35_valid_cases.items(): + with self.subTest(msg=name): + self.con.insert('test', [name, case['python']]) + self.assert_decimal_tuple_equals(name, case['tarantool']) - error_cases = { + legacy_error_cases = { 'decimal_limit_break_head_1': { - 'python': decimal.Decimal('999999999999999999999999999999999999999'), + 'python': decimal.Decimal('9' * 39), }, 'decimal_limit_break_head_2': { - 'python': decimal.Decimal('-999999999999999999999999999999999999999'), + 'python': decimal.Decimal('-' + '9' * 39), }, 'decimal_limit_break_head_3': { - 'python': decimal.Decimal('999999999999999999900000099999999999999999999'), + 'python': decimal.Decimal('9' * 19 + '0' * 5 + '9' * 20), }, 'decimal_limit_break_head_4': { - 'python': decimal.Decimal('-999999999999999999900000099999999999999999999'), + 'python': decimal.Decimal('-' + '9' * 19 + '0' * 5 + '9' * 20), }, 'decimal_limit_break_head_5': { - 'python': decimal.Decimal('100000000000000000000000000000000000000.1'), + 'python': decimal.Decimal('1' + '0' * 38 + '.1'), }, 'decimal_limit_break_head_6': { - 'python': decimal.Decimal('-100000000000000000000000000000000000000.1'), + 'python': decimal.Decimal('-1' + '0' * 38 + '.1'), }, 'decimal_limit_break_head_7': { - 'python': decimal.Decimal('1000000000000000000011110000000000000000000.1'), + 'python': decimal.Decimal('1' + '0' * 18 + '1' * 4 + '0' * 19 + '.1'), }, 'decimal_limit_break_head_8': { - 'python': decimal.Decimal('-1000000000000000000011110000000000000000000.1'), + 'python': decimal.Decimal('-1' + '0' * 18 + '1' * 4 + '0' * 19 + '.1'), + }, + } + + v35_error_cases = { + 'decimal_v35_limit_break_head_1': { + 'python': decimal.Decimal('9' * 77), + }, + 'decimal_v35_limit_break_head_2': { + 'python': decimal.Decimal('-' + '9' * 77), + }, + 'decimal_v35_limit_break_head_3': { + 'python': decimal.Decimal('1' + '0' * 76 + '.1'), + }, + 'decimal_v35_limit_break_head_4': { + 'python': decimal.Decimal('-1' + '0' * 76 + '.1'), }, } - def test_msgpack_encode_error(self): + def test_msgpack_encode_legacy_error(self): # pylint: disable=cell-var-from-loop - for name, case in self.error_cases.items(): + for name, case in self.legacy_error_cases.items(): with self.subTest(msg=name): msg = 'Decimal cannot be encoded: Tarantool decimal ' + \ 'supports a maximum of 38 digits.' @@ -353,11 +504,26 @@ def test_msgpack_encode_error(self): MsgpackError, msg, lambda: packer_default(case['python'])) + def test_msgpack_encode_v35_error(self): + # pylint: disable=cell-var-from-loop + + for name, case in self.v35_error_cases.items(): + with self.subTest(msg=name): + msg = 'Decimal cannot be encoded: Tarantool decimal ' + \ + 'supports a maximum of 76 digits.' + self.assertRaisesRegex( + MsgpackError, msg, + lambda: packer_default( + case['python'], + tarantool_version=self.decimal_v35_version_id, + )) + @skip_or_run_decimal_test - def test_tarantool_encode_error(self): + @skip_or_run_decimal_before_3_5_test + def test_tarantool_encode_legacy_error(self): # pylint: disable=cell-var-from-loop - for name, case in self.error_cases.items(): + for name, case in self.legacy_error_cases.items(): with self.subTest(msg=name): msg = 'Decimal cannot be encoded: Tarantool decimal ' + \ 'supports a maximum of 38 digits.' @@ -365,69 +531,119 @@ def test_tarantool_encode_error(self): MsgpackError, msg, lambda: self.con.insert('test', [name, case['python']])) - precision_loss_cases = { + @skip_or_run_decimal_test + @skip_or_run_decimal_3_5_test + def test_tarantool_encode_v35_error(self): + # pylint: disable=cell-var-from-loop + + for name, case in self.v35_error_cases.items(): + with self.subTest(msg=name): + msg = 'Decimal cannot be encoded: Tarantool decimal ' + \ + 'supports a maximum of 76 digits.' + self.assertRaisesRegex( + MsgpackError, msg, + lambda: self.con.insert('test', [name, case['python']])) + + legacy_precision_loss_cases = { 'decimal_limit_break_tail_1': { - 'python': decimal.Decimal('1.00000000000000000000000000000000000001'), + 'python': decimal.Decimal('1.' + '0' * 37 + '1'), 'msgpack': (b'\x00\x1c'), 'tarantool': "decimal.new('1')", }, 'decimal_limit_break_tail_2': { - 'python': decimal.Decimal('-1.00000000000000000000000000000000000001'), + 'python': decimal.Decimal('-1.' + '0' * 37 + '1'), 'msgpack': (b'\x00\x1d'), 'tarantool': "decimal.new('-1')", }, 'decimal_limit_break_tail_3': { - 'python': decimal.Decimal('0.000000000000000000000000000000000000001'), + 'python': decimal.Decimal('0.' + '0' * 38 + '1'), 'msgpack': (b'\x00\x0c'), - 'tarantool': "decimal.new('0.000000000000000000000000000000000000001')", + 'tarantool': "decimal.new('0." + '0' * 38 + "1')", }, 'decimal_limit_break_tail_4': { - 'python': decimal.Decimal('-0.000000000000000000000000000000000000001'), + 'python': decimal.Decimal('-0.' + '0' * 38 + '1'), 'msgpack': (b'\x00\x0d'), - 'tarantool': "decimal.new('-0.000000000000000000000000000000000000001')", + 'tarantool': "decimal.new('-0." + '0' * 38 + "1')", }, 'decimal_limit_break_tail_5': { - 'python': decimal.Decimal('9999999.99999900000000000000000000000000000000000001'), + 'python': decimal.Decimal('9999999.999999' + '0' * 38 + '1'), 'msgpack': (b'\x06\x99\x99\x99\x99\x99\x99\x9c'), 'tarantool': "decimal.new('9999999.999999')", }, 'decimal_limit_break_tail_6': { - 'python': decimal.Decimal('-9999999.99999900000000000000000000000000000000000001'), + 'python': decimal.Decimal('-9999999.999999' + '0' * 38 + '1'), 'msgpack': (b'\x06\x99\x99\x99\x99\x99\x99\x9d'), 'tarantool': "decimal.new('-9999999.999999')", }, 'decimal_limit_break_tail_7': { - 'python': decimal.Decimal('99999999999999999999999999999999999999.1'), + 'python': decimal.Decimal('9' * 38 + '.1'), 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x9c'), - 'tarantool': "decimal.new('99999999999999999999999999999999999999')", + 'tarantool': "decimal.new('" + '9' * 38 + "')", }, 'decimal_limit_break_tail_8': { - 'python': decimal.Decimal('-99999999999999999999999999999999999999.1'), + 'python': decimal.Decimal('-' + '9' * 38 + '.1'), 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x9d'), - 'tarantool': "decimal.new('-99999999999999999999999999999999999999')", + 'tarantool': "decimal.new('-" + '9' * 38 + "')", }, 'decimal_limit_break_tail_9': { - 'python': decimal.Decimal('99999999999999999999999999999999999999.11111111111111' - '11111111111'), + 'python': decimal.Decimal('9' * 38 + '.' + '1' * 25), 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x9c'), - 'tarantool': "decimal.new('99999999999999999999999999999999999999')", + 'tarantool': "decimal.new('" + '9' * 38 + "')", }, 'decimal_limit_break_tail_10': { - 'python': decimal.Decimal('-99999999999999999999999999999999999999.11111111111111' - '11111111111'), + 'python': decimal.Decimal('-' + '9' * 38 + '.' + '1' * 25), 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x9d'), - 'tarantool': "decimal.new('-99999999999999999999999999999999999999')", + 'tarantool': "decimal.new('-" + '9' * 38 + "')", + }, + } + + v35_precision_loss_cases = { + 'decimal_v35_limit_break_tail_1': { + 'python': decimal.Decimal('1.' + '0' * 75 + '1'), + 'msgpack': (b'\x00\x1c'), + 'tarantool': "decimal.new('1')", + }, + 'decimal_v35_limit_break_tail_2': { + 'python': decimal.Decimal('-1.' + '0' * 75 + '1'), + 'msgpack': (b'\x00\x1d'), + 'tarantool': "decimal.new('-1')", + }, + 'decimal_v35_limit_break_tail_3': { + 'python': decimal.Decimal('0.' + '0' * 76 + '1'), + 'msgpack': (b'\x00\x0c'), + 'tarantool': "decimal.new('0')", + }, + 'decimal_v35_limit_break_tail_4': { + 'python': decimal.Decimal('-0.' + '0' * 76 + '1'), + 'msgpack': (b'\x00\x0d'), + 'tarantool': "decimal.new('-0')", + }, + 'decimal_v35_limit_break_tail_5': { + 'python': decimal.Decimal('9' * 76 + '.1'), + 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x9c'), + 'tarantool': "decimal.new('" + '9' * 76 + "')", + }, + 'decimal_v35_limit_break_tail_6': { + 'python': decimal.Decimal('-' + '9' * 76 + '.1'), + 'msgpack': (b'\x00\x09\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99\x99' + b'\x99\x99\x99\x99\x99\x99\x9d'), + 'tarantool': "decimal.new('-" + '9' * 76 + "')", }, } - def test_msgpack_encode_with_precision_loss(self): + def test_msgpack_encode_with_legacy_precision_loss(self): # pylint: disable=cell-var-from-loop - for name, case in self.precision_loss_cases.items(): + for name, case in self.legacy_precision_loss_cases.items(): with self.subTest(msg=name): msg = 'Decimal encoded with loss of precision: ' + \ 'Tarantool decimal supports a maximum of 38 digits.' @@ -440,11 +656,31 @@ def test_msgpack_encode_with_precision_loss(self): ) ) + def test_msgpack_encode_with_v35_precision_loss(self): + # pylint: disable=cell-var-from-loop + + for name, case in self.v35_precision_loss_cases.items(): + with self.subTest(msg=name): + msg = 'Decimal encoded with loss of precision: ' + \ + 'Tarantool decimal supports a maximum of 76 digits.' + + self.assertWarnsRegex( + MsgpackWarning, msg, + lambda: self.assertEqual( + packer_default( + case['python'], + tarantool_version=self.decimal_v35_version_id, + ), + msgpack.ExtType(code=1, data=case['msgpack']) + ) + ) + @skip_or_run_decimal_test - def test_tarantool_encode_with_precision_loss(self): + @skip_or_run_decimal_before_3_5_test + def test_tarantool_encode_with_legacy_precision_loss(self): # pylint: disable=cell-var-from-loop - for name, case in self.precision_loss_cases.items(): + for name, case in self.legacy_precision_loss_cases.items(): with self.subTest(msg=name): msg = 'Decimal encoded with loss of precision: ' + \ 'Tarantool decimal supports a maximum of 38 digits.' @@ -452,21 +688,22 @@ def test_tarantool_encode_with_precision_loss(self): self.assertWarnsRegex( MsgpackWarning, msg, lambda: self.con.insert('test', [name, case['python']])) + self.assert_decimal_tuple_equals(name, case['tarantool']) - lua_eval = f""" - local tuple = box.space['test']:get('{name}') - assert(tuple ~= nil) + @skip_or_run_decimal_test + @skip_or_run_decimal_3_5_test + def test_tarantool_encode_with_v35_precision_loss(self): + # pylint: disable=cell-var-from-loop - local dec = {case['tarantool']} - if tuple[2] == dec then - return true - else - return nil, ('%s is not equal to expected %s'):format( - tostring(tuple[2]), tostring(dec)) - end - """ + for name, case in self.v35_precision_loss_cases.items(): + with self.subTest(msg=name): + msg = 'Decimal encoded with loss of precision: ' + \ + 'Tarantool decimal supports a maximum of 76 digits.' - self.assertSequenceEqual(self.con.eval(lua_eval), [True]) + self.assertWarnsRegex( + MsgpackWarning, msg, + lambda: self.con.insert('test', [name, case['python']])) + self.assert_decimal_tuple_equals(name, case['tarantool']) @skip_or_run_decimal_test def test_primary_key(self):