Skip to content

Commit 24a618c

Browse files
msgpack: support decimal extended type
Tarantool supports decimal type since version 2.2.1 [1]. This patch introduced the support of Tarantool decimal type in msgpack decoders and encoders. The Tarantool decimal type is mapped to the native Python decimal.Decimal type. Tarantool decimal numbers have 38 digits of precision, that is, the total number of digits before and after the decimal point can be 38 [2]. If there are more digits arter the decimal point, the precision is lost. If there are more digits before the decimal point, error is thrown. In fact, there is also an exceptional case: if decimal starts with `0.`, 38 digits after the decimal point are supported without the loss of precision. msgpack encoder checks if everything is alright. If number is not a valid Tarantool decimal, the error is raised. If precision will be lost on conversion, warning is issued. Any Tarantool decimal could be converted to a Python decimal without the loss of precision. Python decimals have its own user alterable precision (defaulting to 28 places), but it's related only to arithmetic operations: we can allocate 38-placed decimal disregarding of what decimal module configuration is used [3]. 1. tarantool/tarantool#692 2. https://www.tarantool.io/ru/doc/latest/reference/reference_lua/decimal/ 3. https://docs.python.org/3/library/decimal.html Closed #203
1 parent 3c803bf commit 24a618c

File tree

10 files changed

+513
-2
lines changed

10 files changed

+513
-2
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77
## Unreleased
88

99
### Added
10+
- Decimal type support (#203).
1011

1112
### Changed
1213
- Bump msgpack requirement to 1.0.4 (PR #223).

tarantool/error.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -109,10 +109,20 @@ class ConfigurationError(Error):
109109
Error of initialization with a user-provided configuration.
110110
'''
111111

112+
class MsgpackError(Error):
113+
'''
114+
Error with encoding or decoding of MP_EXT types
115+
'''
116+
117+
class MsgpackWarning(UserWarning):
118+
'''
119+
Warning with encoding or decoding of MP_EXT types
120+
'''
112121

113122
__all__ = ("Warning", "Error", "InterfaceError", "DatabaseError", "DataError",
114123
"OperationalError", "IntegrityError", "InternalError",
115-
"ProgrammingError", "NotSupportedError")
124+
"ProgrammingError", "NotSupportedError", "MsgpackError",
125+
"MsgpackWarning")
116126

117127
# Monkey patch os.strerror for win32
118128
if sys.platform == "win32":

tarantool/ext_types/decimal.py

+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
from decimal import Decimal
2+
3+
from tarantool.error import MsgpackError, MsgpackWarning, warn
4+
5+
# https://www.tarantool.io/en/doc/latest/dev_guide/internals/msgpack_extensions/#the-decimal-type
6+
#
7+
# The decimal MessagePack representation looks like this:
8+
# +--------+-------------------+------------+===============+
9+
# | MP_EXT | length (optional) | MP_DECIMAL | PackedDecimal |
10+
# +--------+-------------------+------------+===============+
11+
#
12+
# PackedDecimal has the following structure:
13+
#
14+
# <--- length bytes -->
15+
# +-------+=============+
16+
# | scale | BCD |
17+
# +-------+=============+
18+
#
19+
# Here scale is either MP_INT or MP_UINT.
20+
# scale = number of digits after the decimal point
21+
#
22+
# BCD is a sequence of bytes representing decimal digits of the encoded number
23+
# (each byte has two decimal digits each encoded using 4-bit nibbles), so
24+
# byte >> 4 is the first digit and byte & 0x0f is the second digit. The
25+
# leftmost digit in the array is the most significant. The rightmost digit in
26+
# the array is the least significant.
27+
#
28+
# The first byte of the BCD array contains the first digit of the number,
29+
# represented as follows:
30+
#
31+
# | 4 bits | 4 bits |
32+
# = 0x = the 1st digit
33+
#
34+
# (The first nibble contains 0 if the decimal number has an even number of
35+
# digits.) The last byte of the BCD array contains the last digit of the number
36+
# and the final nibble, represented as follows:
37+
#
38+
# | 4 bits | 4 bits |
39+
# = the last digit = nibble
40+
#
41+
# The final nibble represents the number’s sign:
42+
#
43+
# 0x0a, 0x0c, 0x0e, 0x0f stand for plus,
44+
# 0x0b and 0x0d stand for minus.
45+
46+
EXT_ID = 1
47+
48+
TARANTOOL_DECIMAL_MAX_DIGITS = 38
49+
50+
def get_mp_sign(sign):
51+
if sign == '+':
52+
return 0x0c
53+
54+
if sign == '-':
55+
return 0x0d
56+
57+
raise RuntimeError
58+
59+
def add_mp_digit(digit, bytes_reverted, digit_count):
60+
if digit_count % 2 == 0:
61+
bytes_reverted[-1] = bytes_reverted[-1] | (digit << 4)
62+
else:
63+
bytes_reverted.append(digit)
64+
65+
def check_valid_tarantool_decimal(str_repr, scale, first_digit_ind):
66+
# Decimal numbers have 38 digits of precision, that is, the total number of
67+
# digits before and after the decimal point can be 38. If there are more
68+
# digits arter the decimal point, the precision is lost. If there are more
69+
# digits before the decimal point, error is thrown.
70+
#
71+
# Tarantool 2.10.1-0-g482d91c66
72+
#
73+
# tarantool> decimal.new('10000000000000000000000000000000000000')
74+
# ---
75+
# - 10000000000000000000000000000000000000
76+
# ...
77+
#
78+
# tarantool> decimal.new('100000000000000000000000000000000000000')
79+
# ---
80+
# - error: '[string "return VERSION"]:1: variable ''VERSION'' is not declared'
81+
# ...
82+
#
83+
# tarantool> decimal.new('1.0000000000000000000000000000000000001')
84+
# ---
85+
# - 1.0000000000000000000000000000000000001
86+
# ...
87+
#
88+
# tarantool> decimal.new('1.00000000000000000000000000000000000001')
89+
# ---
90+
# - 1.0000000000000000000000000000000000000
91+
# ...
92+
#
93+
# In fact, there is also an exceptional case: if decimal starts with `0.`,
94+
# 38 digits after the decimal point are supported without the loss of precision.
95+
#
96+
# tarantool> decimal.new('0.00000000000000000000000000000000000001')
97+
# ---
98+
# - 0.00000000000000000000000000000000000001
99+
# ...
100+
#
101+
# tarantool> decimal.new('0.000000000000000000000000000000000000001')
102+
# ---
103+
# - 0.00000000000000000000000000000000000000
104+
# ...
105+
if scale > 0:
106+
digit_count = len(str_repr) - 1 - first_digit_ind
107+
else:
108+
digit_count = len(str_repr) - first_digit_ind
109+
110+
if digit_count <= TARANTOOL_DECIMAL_MAX_DIGITS:
111+
return True
112+
113+
if (digit_count - scale) > TARANTOOL_DECIMAL_MAX_DIGITS:
114+
raise MsgpackError('Decimal cannot be encoded: Tarantool decimal ' + \
115+
'supports a maximum of 38 digits.')
116+
117+
starts_with_zero = str_repr[first_digit_ind] == '0'
118+
119+
if ( (digit_count > TARANTOOL_DECIMAL_MAX_DIGITS + 1) or \
120+
(digit_count == TARANTOOL_DECIMAL_MAX_DIGITS + 1 \
121+
and not starts_with_zero)):
122+
warn('Decimal encoded with loss of precision: ' + \
123+
'Tarantool decimal supports a maximum of 38 digits.',
124+
MsgpackWarning)
125+
return False
126+
127+
return True
128+
129+
def strip_decimal_str(str_repr, scale, first_digit_ind):
130+
assert scale > 0
131+
# Strip extra bytes
132+
str_repr = str_repr[:TARANTOOL_DECIMAL_MAX_DIGITS + 1 + first_digit_ind]
133+
134+
str_repr.rstrip('0')
135+
str_repr.rstrip('.')
136+
# Do not strips zeroes before the decimal point.
137+
return str_repr
138+
139+
def encode(obj):
140+
# Non-scientific string with trailing zeroes removed
141+
str_repr = format(obj, 'f')
142+
143+
bytes_reverted = bytearray()
144+
145+
scale = 0
146+
for i in range(len(str_repr)):
147+
str_digit = str_repr[i]
148+
if str_digit == '.':
149+
scale = len(str_repr) - i - 1
150+
break
151+
152+
if str_repr[0] == '-':
153+
sign = '-'
154+
first_digit_ind = 1
155+
else:
156+
sign = '+'
157+
first_digit_ind = 0
158+
159+
if not check_valid_tarantool_decimal(str_repr, scale, first_digit_ind):
160+
str_repr = strip_decimal_str(str_repr, scale, first_digit_ind)
161+
162+
bytes_reverted.append(get_mp_sign(sign))
163+
164+
digit_count = 0
165+
166+
for i in range(len(str_repr) - 1, first_digit_ind - 1, -1):
167+
str_digit = str_repr[i]
168+
if str_digit == '.':
169+
scale = len(str_repr) - i - 1
170+
continue
171+
172+
add_mp_digit(int(str_digit), bytes_reverted, digit_count)
173+
digit_count = digit_count + 1
174+
175+
# Remove leading zeroes since they already covered by scale
176+
for i in range(len(bytes_reverted)):
177+
if bytes_reverted[i] != 0:
178+
break
179+
bytes_reverted.pop()
180+
181+
bytes_reverted.append(scale)
182+
183+
return bytes(bytes_reverted[::-1])
184+
185+
186+
def get_str_sign(nibble):
187+
if nibble == 0x0a or nibble == 0x0c or nibble == 0x0e or nibble == 0x0f:
188+
return '+'
189+
190+
if nibble == 0x0b or nibble == 0x0d:
191+
return '-'
192+
193+
raise MsgpackError('Unexpected MP_DECIMAL sign nibble')
194+
195+
def add_str_digit(digit, digits_reverted, scale):
196+
if not (0 <= digit <= 9):
197+
raise MsgpackError('Unexpected MP_DECIMAL digit nibble')
198+
199+
if len(digits_reverted) == scale:
200+
digits_reverted.append('.')
201+
202+
digits_reverted.append(str(digit))
203+
204+
def decode(data):
205+
scale = data[0]
206+
207+
sign = get_str_sign(data[-1] & 0x0f)
208+
209+
# Parse from tail since scale is counted from the tail.
210+
digits_reverted = []
211+
212+
add_str_digit((data[-1] & 0xf0) >> 4, digits_reverted, scale)
213+
214+
for i in range(len(data) - 2, 0, -1):
215+
add_str_digit(data[i] & 0x0f, digits_reverted, scale)
216+
add_str_digit((data[i] & 0xf0) >> 4, digits_reverted, scale)
217+
218+
# Add leading zeroes in case of 0.000... number.
219+
for i in range(len(digits_reverted), scale + 1):
220+
add_str_digit(0, digits_reverted, scale)
221+
222+
digits_reverted.append(sign)
223+
224+
str_repr = ''.join(digits_reverted[::-1])
225+
226+
return Decimal(str_repr)

tarantool/ext_types/packer.py

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
from decimal import Decimal
2+
from msgpack import ExtType
3+
4+
import tarantool.ext_types.decimal as ext_decimal
5+
6+
def default(obj):
7+
if isinstance(obj, Decimal):
8+
return ExtType(ext_decimal.EXT_ID, ext_decimal.encode(obj))
9+
raise TypeError("Unknown type: %r" % (obj,))

tarantool/ext_types/unpacker.py

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from decimal import Decimal
2+
3+
import tarantool.ext_types.decimal as ext_decimal
4+
5+
def ext_hook(code, data):
6+
if code == ext_decimal.EXT_ID:
7+
return ext_decimal.decode(data)
8+
raise NotImplementedError("Unknown msgpack type: %d" % (code,))

tarantool/request.py

+4
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
binary_types
6060
)
6161

62+
from tarantool.ext_types.packer import default as packer_default
63+
6264
class Request(object):
6365
'''
6466
Represents a single request to the server in compliance with the
@@ -122,6 +124,8 @@ def __init__(self, conn):
122124
else:
123125
packer_kwargs['use_bin_type'] = True
124126

127+
packer_kwargs['default'] = packer_default
128+
125129
self.packer = msgpack.Packer(**packer_kwargs)
126130

127131
def _dumps(self, src):

tarantool/response.py

+3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
tnt_strerror
3030
)
3131

32+
from tarantool.ext_types.unpacker import ext_hook as unpacker_ext_hook
3233

3334
class Response(Sequence):
3435
'''
@@ -86,6 +87,8 @@ def __init__(self, conn, response):
8687
if msgpack.version >= (1, 0, 0):
8788
unpacker_kwargs['strict_map_key'] = False
8889

90+
unpacker_kwargs['ext_hook'] = unpacker_ext_hook
91+
8992
unpacker = msgpack.Unpacker(**unpacker_kwargs)
9093

9194
unpacker.feed(response)

test/suites/__init__.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
from .test_dbapi import TestSuite_DBAPI
1616
from .test_encoding import TestSuite_Encoding
1717
from .test_ssl import TestSuite_Ssl
18+
from .test_ext_types import TestSuite_ExtTypes
1819

1920
test_cases = (TestSuite_Schema_UnicodeConnection,
2021
TestSuite_Schema_BinaryConnection,
2122
TestSuite_Request, TestSuite_Protocol, TestSuite_Reconnect,
2223
TestSuite_Mesh, TestSuite_Execute, TestSuite_DBAPI,
23-
TestSuite_Encoding, TestSuite_Pool, TestSuite_Ssl)
24+
TestSuite_Encoding, TestSuite_Pool, TestSuite_Ssl, TestSuite_ExtTypes)
2425

2526
def load_tests(loader, tests, pattern):
2627
suite = unittest.TestSuite()

test/suites/lib/skip.py

+42
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,38 @@ def wrapper(self, *args, **kwargs):
4343

4444
return wrapper
4545

46+
def skip_or_run_test_pcall_require(func, REQUIRED_TNT_MODULE, msg):
47+
"""Decorator to skip or run tests depending on tarantool
48+
module requre success or fail.
49+
50+
Also, it can be used with the 'setUp' method for skipping
51+
the whole test suite.
52+
"""
53+
54+
@functools.wraps(func)
55+
def wrapper(self, *args, **kwargs):
56+
if func.__name__ == 'setUp':
57+
func(self, *args, **kwargs)
58+
59+
srv = None
60+
61+
if hasattr(self, 'servers'):
62+
srv = self.servers[0]
63+
64+
if hasattr(self, 'srv'):
65+
srv = self.srv
66+
67+
assert srv is not None
68+
69+
resp = srv.admin("pcall(require, '%s')" % REQUIRED_TNT_MODULE)
70+
if not resp[0]:
71+
self.skipTest('Tarantool %s' % (msg, ))
72+
73+
if func.__name__ != 'setUp':
74+
func(self, *args, **kwargs)
75+
76+
return wrapper
77+
4678

4779
def skip_or_run_test_python(func, REQUIRED_PYTHON_VERSION, msg):
4880
"""Decorator to skip or run tests depending on the Python version.
@@ -101,3 +133,13 @@ def skip_or_run_conn_pool_test(func):
101133
return skip_or_run_test_python(func, '3.7',
102134
'does not support ConnectionPool')
103135

136+
def skip_or_run_decimal_test(func):
137+
"""Decorator to skip or run decimal-related tests depending on
138+
the tarantool version.
139+
140+
Tarantool supports decimal type only since 2.2.1 version.
141+
See https://github.com/tarantool/tarantool/issues/692
142+
"""
143+
144+
return skip_or_run_test_pcall_require(func, 'decimal',
145+
'does not support decimal type')

0 commit comments

Comments
 (0)