1# Copyright (c) 2011 Mitch Garnaat http://garnaat.org/
2# Copyright (c) 2011 Amazon.com, Inc. or its affiliates.  All Rights Reserved
3#
4# Permission is hereby granted, free of charge, to any person obtaining a
5# copy of this software and associated documentation files (the
6# "Software"), to deal in the Software without restriction, including
7# without limitation the rights to use, copy, modify, merge, publish, dis-
8# tribute, sublicense, and/or sell copies of the Software, and to permit
9# persons to whom the Software is furnished to do so, subject to the fol-
10# lowing conditions:
11#
12# The above copyright notice and this permission notice shall be included
13# in all copies or substantial portions of the Software.
14#
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
17# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
18# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
21# IN THE SOFTWARE.
22#
23"""
24Some utility functions to deal with mapping Amazon DynamoDB types to
25Python types and vice-versa.
26"""
27import base64
28from decimal import (Decimal, DecimalException, Context,
29                     Clamped, Overflow, Inexact, Underflow, Rounded)
30from collections import Mapping
31from boto.dynamodb.exceptions import DynamoDBNumberError
32from boto.compat import filter, map, six, long_type
33
34
35DYNAMODB_CONTEXT = Context(
36    Emin=-128, Emax=126, rounding=None, prec=38,
37    traps=[Clamped, Overflow, Inexact, Rounded, Underflow])
38
39
40# python2.6 cannot convert floats directly to
41# Decimals.  This is taken from:
42# http://docs.python.org/release/2.6.7/library/decimal.html#decimal-faq
43def float_to_decimal(f):
44    n, d = f.as_integer_ratio()
45    numerator, denominator = Decimal(n), Decimal(d)
46    ctx = DYNAMODB_CONTEXT
47    result = ctx.divide(numerator, denominator)
48    while ctx.flags[Inexact]:
49        ctx.flags[Inexact] = False
50        ctx.prec *= 2
51        result = ctx.divide(numerator, denominator)
52    return result
53
54
55def is_num(n, boolean_as_int=True):
56    if boolean_as_int:
57        types = (int, long_type, float, Decimal, bool)
58    else:
59        types = (int, long_type, float, Decimal)
60
61    return isinstance(n, types) or n in types
62
63
64if six.PY2:
65    def is_str(n):
66        return (isinstance(n, basestring) or
67                isinstance(n, type) and issubclass(n, basestring))
68
69    def is_binary(n):
70        return isinstance(n, Binary)
71
72else:  # PY3
73    def is_str(n):
74        return (isinstance(n, str) or
75                isinstance(n, type) and issubclass(n, str))
76
77    def is_binary(n):
78        return isinstance(n, bytes)  # Binary is subclass of bytes.
79
80
81def serialize_num(val):
82    """Cast a number to a string and perform
83       validation to ensure no loss of precision.
84    """
85    if isinstance(val, bool):
86        return str(int(val))
87    return str(val)
88
89
90def convert_num(s):
91    if '.' in s:
92        n = float(s)
93    else:
94        n = int(s)
95    return n
96
97
98def convert_binary(n):
99    return Binary(base64.b64decode(n))
100
101
102def get_dynamodb_type(val, use_boolean=True):
103    """
104    Take a scalar Python value and return a string representing
105    the corresponding Amazon DynamoDB type.  If the value passed in is
106    not a supported type, raise a TypeError.
107    """
108    dynamodb_type = None
109    if val is None:
110        dynamodb_type = 'NULL'
111    elif is_num(val):
112        if isinstance(val, bool) and use_boolean:
113            dynamodb_type = 'BOOL'
114        else:
115            dynamodb_type = 'N'
116    elif is_str(val):
117        dynamodb_type = 'S'
118    elif isinstance(val, (set, frozenset)):
119        if False not in map(is_num, val):
120            dynamodb_type = 'NS'
121        elif False not in map(is_str, val):
122            dynamodb_type = 'SS'
123        elif False not in map(is_binary, val):
124            dynamodb_type = 'BS'
125    elif is_binary(val):
126        dynamodb_type = 'B'
127    elif isinstance(val, Mapping):
128        dynamodb_type = 'M'
129    elif isinstance(val, list):
130        dynamodb_type = 'L'
131    if dynamodb_type is None:
132        msg = 'Unsupported type "%s" for value "%s"' % (type(val), val)
133        raise TypeError(msg)
134    return dynamodb_type
135
136
137def dynamize_value(val):
138    """
139    Take a scalar Python value and return a dict consisting
140    of the Amazon DynamoDB type specification and the value that
141    needs to be sent to Amazon DynamoDB.  If the type of the value
142    is not supported, raise a TypeError
143    """
144    dynamodb_type = get_dynamodb_type(val)
145    if dynamodb_type == 'N':
146        val = {dynamodb_type: serialize_num(val)}
147    elif dynamodb_type == 'S':
148        val = {dynamodb_type: val}
149    elif dynamodb_type == 'NS':
150        val = {dynamodb_type: list(map(serialize_num, val))}
151    elif dynamodb_type == 'SS':
152        val = {dynamodb_type: [n for n in val]}
153    elif dynamodb_type == 'B':
154        if isinstance(val, bytes):
155            val = Binary(val)
156        val = {dynamodb_type: val.encode()}
157    elif dynamodb_type == 'BS':
158        val = {dynamodb_type: [n.encode() for n in val]}
159    return val
160
161
162if six.PY2:
163    class Binary(object):
164        def __init__(self, value):
165            if not isinstance(value, (bytes, six.text_type)):
166                raise TypeError('Value must be a string of binary data!')
167            if not isinstance(value, bytes):
168                value = value.encode("utf-8")
169
170            self.value = value
171
172        def encode(self):
173            return base64.b64encode(self.value).decode('utf-8')
174
175        def __eq__(self, other):
176            if isinstance(other, Binary):
177                return self.value == other.value
178            else:
179                return self.value == other
180
181        def __ne__(self, other):
182            return not self.__eq__(other)
183
184        def __repr__(self):
185            return 'Binary(%r)' % self.value
186
187        def __str__(self):
188            return self.value
189
190        def __hash__(self):
191            return hash(self.value)
192else:
193    class Binary(bytes):
194        def encode(self):
195            return base64.b64encode(self).decode('utf-8')
196
197        @property
198        def value(self):
199            # This matches the public API of the Python 2 version,
200            # but just returns itself since it is already a bytes
201            # instance.
202            return bytes(self)
203
204        def __repr__(self):
205            return 'Binary(%r)' % self.value
206
207
208def item_object_hook(dct):
209    """
210    A custom object hook for use when decoding JSON item bodys.
211    This hook will transform Amazon DynamoDB JSON responses to something
212    that maps directly to native Python types.
213    """
214    if len(dct.keys()) > 1:
215        return dct
216    if 'S' in dct:
217        return dct['S']
218    if 'N' in dct:
219        return convert_num(dct['N'])
220    if 'SS' in dct:
221        return set(dct['SS'])
222    if 'NS' in dct:
223        return set(map(convert_num, dct['NS']))
224    if 'B' in dct:
225        return convert_binary(dct['B'])
226    if 'BS' in dct:
227        return set(map(convert_binary, dct['BS']))
228    return dct
229
230
231class Dynamizer(object):
232    """Control serialization/deserialization of types.
233
234    This class controls the encoding of python types to the
235    format that is expected by the DynamoDB API, as well as
236    taking DynamoDB types and constructing the appropriate
237    python types.
238
239    If you want to customize this process, you can subclass
240    this class and override the encoding/decoding of
241    specific types.  For example::
242
243        'foo'      (Python type)
244            |
245            v
246        encode('foo')
247            |
248            v
249        _encode_s('foo')
250            |
251            v
252        {'S': 'foo'}  (Encoding sent to/received from DynamoDB)
253            |
254            V
255        decode({'S': 'foo'})
256            |
257            v
258        _decode_s({'S': 'foo'})
259            |
260            v
261        'foo'     (Python type)
262
263    """
264    def _get_dynamodb_type(self, attr):
265        return get_dynamodb_type(attr)
266
267    def encode(self, attr):
268        """
269        Encodes a python type to the format expected
270        by DynamoDB.
271
272        """
273        dynamodb_type = self._get_dynamodb_type(attr)
274        try:
275            encoder = getattr(self, '_encode_%s' % dynamodb_type.lower())
276        except AttributeError:
277            raise ValueError("Unable to encode dynamodb type: %s" %
278                             dynamodb_type)
279        return {dynamodb_type: encoder(attr)}
280
281    def _encode_n(self, attr):
282        try:
283            if isinstance(attr, float) and not hasattr(Decimal, 'from_float'):
284                # python2.6 does not support creating Decimals directly
285                # from floats so we have to do this ourself.
286                n = str(float_to_decimal(attr))
287            else:
288                n = str(DYNAMODB_CONTEXT.create_decimal(attr))
289            if list(filter(lambda x: x in n, ('Infinity', 'NaN'))):
290                raise TypeError('Infinity and NaN not supported')
291            return n
292        except (TypeError, DecimalException) as e:
293            msg = '{0} numeric for `{1}`\n{2}'.format(
294                e.__class__.__name__, attr, str(e) or '')
295        raise DynamoDBNumberError(msg)
296
297    def _encode_s(self, attr):
298        if isinstance(attr, bytes):
299            attr = attr.decode('utf-8')
300        elif not isinstance(attr, six.text_type):
301            attr = str(attr)
302        return attr
303
304    def _encode_ns(self, attr):
305        return list(map(self._encode_n, attr))
306
307    def _encode_ss(self, attr):
308        return [self._encode_s(n) for n in attr]
309
310    def _encode_b(self, attr):
311        if isinstance(attr, bytes):
312            attr = Binary(attr)
313        return attr.encode()
314
315    def _encode_bs(self, attr):
316        return [self._encode_b(n) for n in attr]
317
318    def _encode_null(self, attr):
319        return True
320
321    def _encode_bool(self, attr):
322        return attr
323
324    def _encode_m(self, attr):
325        return dict([(k, self.encode(v)) for k, v in attr.items()])
326
327    def _encode_l(self, attr):
328        return [self.encode(i) for i in attr]
329
330    def decode(self, attr):
331        """
332        Takes the format returned by DynamoDB and constructs
333        the appropriate python type.
334
335        """
336        if len(attr) > 1 or not attr:
337            return attr
338        dynamodb_type = list(attr.keys())[0]
339        if dynamodb_type.lower() == dynamodb_type:
340            # It's not an actual type, just a single character attr that
341            # overlaps with the DDB types. Return it.
342            return attr
343        try:
344            decoder = getattr(self, '_decode_%s' % dynamodb_type.lower())
345        except AttributeError:
346            return attr
347        return decoder(attr[dynamodb_type])
348
349    def _decode_n(self, attr):
350        return DYNAMODB_CONTEXT.create_decimal(attr)
351
352    def _decode_s(self, attr):
353        return attr
354
355    def _decode_ns(self, attr):
356        return set(map(self._decode_n, attr))
357
358    def _decode_ss(self, attr):
359        return set(map(self._decode_s, attr))
360
361    def _decode_b(self, attr):
362        return convert_binary(attr)
363
364    def _decode_bs(self, attr):
365        return set(map(self._decode_b, attr))
366
367    def _decode_null(self, attr):
368        return None
369
370    def _decode_bool(self, attr):
371        return attr
372
373    def _decode_m(self, attr):
374        return dict([(k, self.decode(v)) for k, v in attr.items()])
375
376    def _decode_l(self, attr):
377        return [self.decode(i) for i in attr]
378
379
380class NonBooleanDynamizer(Dynamizer):
381    """Casting boolean type to numeric types.
382
383    This class is provided for backward compatibility.
384    """
385    def _get_dynamodb_type(self, attr):
386        return get_dynamodb_type(attr, use_boolean=False)
387
388
389class LossyFloatDynamizer(NonBooleanDynamizer):
390    """Use float/int instead of Decimal for numeric types.
391
392    This class is provided for backwards compatibility.  Instead of
393    using Decimals for the 'N', 'NS' types it uses ints/floats.
394
395    This class is deprecated and its usage is not encouraged,
396    as doing so may result in loss of precision.  Use the
397    `Dynamizer` class instead.
398
399    """
400    def _encode_n(self, attr):
401        return serialize_num(attr)
402
403    def _encode_ns(self, attr):
404        return [str(i) for i in attr]
405
406    def _decode_n(self, attr):
407        return convert_num(attr)
408
409    def _decode_ns(self, attr):
410        return set(map(self._decode_n, attr))
411