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