• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

pynamodb / PynamoDB / 3626608382

pending completion
3626608382

Pull #1126

github

GitHub
Merge 9fe1f784b into 0ab79ce80
Pull Request #1126: Replace to_dict with to_simple_dict, to_dynamodb_dict

110 of 116 new or added lines in 3 files covered. (94.83%)

6 existing lines in 1 file now uncovered.

2905 of 3066 relevant lines covered (94.75%)

4.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

91.95
/pynamodb/attributes.py
1
"""
2
PynamoDB attributes
3
"""
4
import base64
5✔
5
import calendar
5✔
6
import collections.abc
5✔
7
import json
5✔
8
import time
5✔
9
import warnings
5✔
10
from base64 import b64encode, b64decode
5✔
11
from copy import deepcopy
5✔
12
from datetime import datetime
5✔
13
from datetime import timedelta
5✔
14
from datetime import timezone
5✔
15
from inspect import getfullargspec
5✔
16
from inspect import getmembers
5✔
17
from typing import Any, Callable, Dict, Generic, List, Mapping, Optional, TypeVar, Type, Union, Set, overload, Iterable
5✔
18
from typing import TYPE_CHECKING
5✔
19

20
from pynamodb.constants import BINARY
5✔
21
from pynamodb.constants import BINARY_SET
5✔
22
from pynamodb.constants import BOOLEAN
5✔
23
from pynamodb.constants import DATETIME_FORMAT
5✔
24
from pynamodb.constants import LIST
5✔
25
from pynamodb.constants import MAP
5✔
26
from pynamodb.constants import NULL
5✔
27
from pynamodb.constants import NUMBER
5✔
28
from pynamodb.constants import NUMBER_SET
5✔
29
from pynamodb.constants import STRING
5✔
30
from pynamodb.constants import STRING_SET
5✔
31
from pynamodb.exceptions import AttributeDeserializationError
5✔
32
from pynamodb.exceptions import AttributeNullError
5✔
33
from pynamodb.expressions.operand import Path
5✔
34

35

36
if TYPE_CHECKING:
5✔
37
    from pynamodb.expressions.condition import (
×
38
        BeginsWith, Between, Comparison, Contains, NotExists, Exists, In
39
    )
40
    from pynamodb.expressions.operand import (
×
41
        _Decrement, _IfNotExists, _Increment, _ListAppend
42
    )
43
    from pynamodb.expressions.update import (
×
44
        AddAction, DeleteAction, RemoveAction, SetAction
45
    )
46

47

48
_T = TypeVar('_T')
5✔
49
_KT = TypeVar('_KT', bound=str)
5✔
50
_VT = TypeVar('_VT')
5✔
51
_MT = TypeVar('_MT', bound='MapAttribute')
5✔
52
_ACT = TypeVar('_ACT', bound = 'AttributeContainer')
5✔
53

54
_A = TypeVar('_A', bound='Attribute')
5✔
55

56
_IMMUTABLE_TYPES = (str, int, float, datetime, timedelta, bytes, bool, tuple, frozenset, type(None))
5✔
57
_IMMUTABLE_TYPE_NAMES = ', '.join(map(lambda x: x.__name__, _IMMUTABLE_TYPES))
5✔
58

59

60
class Attribute(Generic[_T]):
5✔
61
    """
62
    An attribute of a model or an index.
63

64
    :param hash_key: If `True`, this attribute is a model's or an index's hash key (partition key).
65
    :param range_key: If `True`, this attribute is a model's or an index's range key (sort key).
66
    :param null: If `True`, a `None` value would be considered valid and would result in the attribute
67
      not being set in the underlying DynamoDB item. If `False` (default), an exception will be raised when
68
      the attribute is persisted with a `None` value.
69

70
      .. note::
71
         This is different from :class:`pynamodb.attributes.NullAttribute`, which manifests in a `NULL`-typed
72
         DynamoDB attribute value.
73

74
    :param default: A default value that will be assigned in new models (when they are initialized)
75
      and existing models (when they are loaded).
76

77
      .. note::
78
         Starting with PynamoDB 6.0, the default must be either an immutable value (of one of the built-in
79
         immutable types) or a callable. This prevents a common class of errors caused by unintentionally mutating
80
         the default value. A simple workaround is to pass an initializer (e.g. change :code:`default={}` to
81
         :code:`default=dict`) or wrap in a lambda (e.g. change :code:`default={'foo': 'bar'}` to
82
         :code:`default=lambda: {'foo': 'bar'}`).
83

84
    :param default_for_new: Like `default`, but used only for new models. Use this to assign a default
85
      for new models that you don't want to apply to existing models when they are loaded and then re-saved.
86

87
      .. note::
88
         Starting with PynamoDB 6.0, the default must be either an immutable value (of one of the built-in
89
         immutable types) or a callable.
90

91
    :param attr_name: The name that is used for the attribute in the underlying DynamoDB item;
92
        use this to assign a "pythonic" name that is different from the persisted name, i.e.
93

94
        .. code-block:: python
95

96
          number_of_threads = NumberAttribute(attr_name='thread_count')
97
    """
98
    attr_type: str
5✔
99
    null = False
5✔
100

101
    def __init__(
5✔
102
        self,
103
        hash_key: bool = False,
104
        range_key: bool = False,
105
        null: Optional[bool] = None,
106
        default: Optional[Union[_T, Callable[..., _T]]] = None,
107
        default_for_new: Optional[Union[Any, Callable[..., _T]]] = None,
108
        attr_name: Optional[str] = None,
109
    ) -> None:
110
        if default and default_for_new:
5✔
111
            raise ValueError("An attribute cannot have both default and default_for_new parameters")
5✔
112
        if not callable(default) and not isinstance(default, _IMMUTABLE_TYPES):
5✔
113
            raise ValueError(
5✔
114
                f"An attribute's 'default' must be immutable ({_IMMUTABLE_TYPE_NAMES}) or a callable "
115
                "(see https://pynamodb.readthedocs.io/en/latest/api.html#pynamodb.attributes.Attribute)"
116
            )
117
        if not callable(default_for_new) and not isinstance(default_for_new, _IMMUTABLE_TYPES):
5✔
118
            raise ValueError(
5✔
119
                f"An attribute's 'default_for_new' must be immutable ({_IMMUTABLE_TYPE_NAMES}) or a callable "
120
                "(see https://pynamodb.readthedocs.io/en/latest/api.html#pynamodb.attributes.Attribute)"
121
            )
122
        self.default = default
5✔
123
        self.default_for_new = default_for_new
5✔
124

125
        if null is not None:
5✔
126
            self.null = null
5✔
127
        self.is_hash_key = hash_key
5✔
128
        self.is_range_key = range_key
5✔
129

130
        # __set_name__ will ensure this is a string
131
        self.attr_path: List[str] = [attr_name]  # type: ignore
5✔
132

133
    @property
5✔
134
    def attr_name(self) -> str:
5✔
135
        return self.attr_path[-1]
5✔
136

137
    @attr_name.setter
5✔
138
    def attr_name(self, value: str) -> None:
5✔
139
        self.attr_path[-1] = value
5✔
140

141
    def __set__(self, instance: Any, value: Optional[_T]) -> None:
5✔
142
        if instance and not self._is_map_attribute_class_object(instance):
5✔
143
            attr_name = instance._dynamo_to_python_attrs.get(self.attr_name, self.attr_name)
5✔
144
            instance.attribute_values[attr_name] = value
5✔
145

146
    @overload
5✔
147
    def __get__(self: _A, instance: None, owner: Any) -> _A: ...
5✔
148

149
    @overload
5✔
150
    def __get__(self: _A, instance: Any, owner: Any) -> _T: ...
5✔
151

152
    def __get__(self: _A, instance: Any, owner: Any) -> Union[_A, _T]:
5✔
153
        if self._is_map_attribute_class_object(instance):
5✔
154
            # MapAttribute class objects store a local copy of the attribute with `attr_path` set to the document path.
155
            attr_name = instance._dynamo_to_python_attrs.get(self.attr_name, self.attr_name)
5✔
156
            return instance.__dict__.get(attr_name, None) or self
5✔
157
        elif instance:
5✔
158
            attr_name = instance._dynamo_to_python_attrs.get(self.attr_name, self.attr_name)
5✔
159
            return instance.attribute_values.get(attr_name, None)
5✔
160
        else:
161
            return self
5✔
162

163
    def __set_name__(self, owner: Type[Any], name: str) -> None:
5✔
164
        self.attr_name = self.attr_name or name
5✔
165

166
    def _is_map_attribute_class_object(self, instance: 'Attribute') -> bool:
5✔
167
        return isinstance(instance, MapAttribute) and not instance._is_attribute_container()
5✔
168

169
    def serialize(self, value: Any) -> Any:
5✔
170
        """
171
        Serializes a value for botocore's DynamoDB client.
172

173
        For a list of DynamoDB attribute types and their matching botocore Python types,
174
        see `DynamoDB.Client.get_item API reference
175
        <https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Client.get_item>`_.
176
        """
177
        return value
5✔
178

179
    def deserialize(self, value: Any) -> Any:
5✔
180
        """
181
        Deserializes a value from botocore's DynamoDB client.
182

183
        For a list of DynamoDB attribute types and their matching botocore Python types,
184
        see `DynamoDB.Client.get_item API reference
185
        <https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/dynamodb.html#DynamoDB.Client.get_item>`_.
186
        """
187
        return value
5✔
188

189
    def get_value(self, value: Dict[str, Any]) -> Any:
5✔
190
        if self.attr_type not in value:
5✔
191
            raise AttributeDeserializationError(self.attr_name, self.attr_type)
5✔
192
        return value[self.attr_type]
5✔
193

194
    def __iter__(self):
5✔
195
        # Because we define __getitem__ below for condition expression support
196
        raise TypeError("'{}' object is not iterable".format(self.__class__.__name__))
×
197

198
    # Condition Expression Support
199
    def __eq__(self, other: Any) -> 'Comparison':  # type: ignore[override]
5✔
200
        return Path(self).__eq__(other)
5✔
201

202
    def __ne__(self, other: Any) -> 'Comparison':  # type: ignore[override]
5✔
203
        return Path(self).__ne__(other)
5✔
204

205
    def __lt__(self, other: Any) -> 'Comparison':
5✔
206
        return Path(self).__lt__(other)
5✔
207

208
    def __le__(self, other: Any) -> 'Comparison':
5✔
209
        return Path(self).__le__(other)
5✔
210

211
    def __gt__(self, other: Any) -> 'Comparison':
5✔
212
        return Path(self).__gt__(other)
5✔
213

214
    def __ge__(self, other: Any) -> 'Comparison':
5✔
215
        return Path(self).__ge__(other)
5✔
216

217
    def __getitem__(self, item: Union[int, str]) -> Path:
5✔
218
        return Path(self).__getitem__(item)
5✔
219

220
    def between(self, lower: Any, upper: Any) -> 'Between':
5✔
221
        return Path(self).between(lower, upper)
5✔
222

223
    def is_in(self, *values: _T) -> 'In':
5✔
224
        return Path(self).is_in(*values)
5✔
225

226
    def exists(self) -> 'Exists':
5✔
227
        return Path(self).exists()
5✔
228

229
    def does_not_exist(self) -> 'NotExists':
5✔
230
        return Path(self).does_not_exist()
5✔
231

232
    def is_type(self):
5✔
233
        # What makes sense here? Are we using this to check if deserialization will be successful?
234
        return Path(self).is_type(self.attr_type)
5✔
235

236
    def startswith(self, prefix: str) -> 'BeginsWith':
5✔
237
        return Path(self).startswith(prefix)
5✔
238

239
    def contains(self, item: Any) -> 'Contains':
5✔
240
        return Path(self).contains(item)
5✔
241

242
    # Update Expression Support
243
    def __add__(self, other: Any) -> '_Increment':
5✔
244
        return Path(self).__add__(other)
×
245

246
    def __radd__(self, other: Any) -> '_Increment':
5✔
247
        return Path(self).__radd__(other)
×
248

249
    def __sub__(self, other: Any) -> '_Decrement':
5✔
250
        return Path(self).__sub__(other)
×
251

252
    def __rsub__(self, other: Any) -> '_Decrement':
5✔
253
        return Path(self).__rsub__(other)
×
254

255
    def __or__(self, other: Any) -> '_IfNotExists':
5✔
256
        return Path(self).__or__(other)
×
257

258
    def append(self, other: Iterable) -> '_ListAppend':
5✔
259
        return Path(self).append(other)
×
260

261
    def prepend(self, other: Iterable) -> '_ListAppend':
5✔
262
        return Path(self).prepend(other)
×
263

264
    def set(
5✔
265
        self,
266
        value: Union[_T, 'Attribute[_T]', '_Increment', '_Decrement', '_IfNotExists', '_ListAppend']
267
    ) -> 'SetAction':
268
        return Path(self).set(value)
5✔
269

270
    def remove(self) -> 'RemoveAction':
5✔
271
        return Path(self).remove()
5✔
272

273
    def add(self, *values: Any) -> 'AddAction':
5✔
274
        return Path(self).add(*values)
5✔
275

276
    def delete(self, *values: Any) -> 'DeleteAction':
5✔
277
        return Path(self).delete(*values)
5✔
278

279

280
class AttributeContainerMeta(type):
5✔
281
    _attributes: Dict[str, Attribute]
5✔
282

283
    def __new__(cls, name, bases, namespace, discriminator=None):
5✔
284
        # Defined so that the discriminator can be set in the class definition.
285
        return super().__new__(cls, name, bases, namespace)
5✔
286

287
    def __init__(self, name, bases, namespace, discriminator=None):
5✔
288
        super().__init__(name, bases, namespace)
5✔
289
        AttributeContainerMeta._initialize_attributes(self, discriminator)
5✔
290

291
    @staticmethod
5✔
292
    def _initialize_attributes(cls, discriminator_value):
3✔
293
        """
294
        Initialize attributes on the class.
295
        """
296
        cls._attributes = {}
5✔
297
        cls._dynamo_to_python_attrs = {}
5✔
298

299
        for name, attribute in getmembers(cls, lambda o: isinstance(o, Attribute)):
5✔
300
            cls._attributes[name] = attribute
5✔
301
            if attribute.attr_name != name:
5✔
302
                cls._dynamo_to_python_attrs[attribute.attr_name] = name
5✔
303

304
        # Register the class with the discriminator if necessary.
305
        discriminators = [name for name, attr in cls._attributes.items() if isinstance(attr, DiscriminatorAttribute)]
5✔
306
        if len(discriminators) > 1:
5✔
307
            raise ValueError("{} has more than one discriminator attribute: {}".format(
×
308
                cls.__name__, ", ".join(discriminators)))
309
        cls._discriminator = discriminators[0] if discriminators else None
5✔
310
        if discriminator_value is not None:
5✔
311
            if not cls._discriminator:
5✔
312
                raise ValueError("{} does not have a discriminator attribute".format(cls.__name__))
×
313
            cls._attributes[cls._discriminator].register_class(cls, discriminator_value)
5✔
314

315

316
class AttributeContainer(metaclass=AttributeContainerMeta):
5✔
317

318
    def __init__(self, _user_instantiated: bool = True, **attributes: Attribute) -> None:
5✔
319
        # The `attribute_values` dictionary is used by the Attribute data descriptors in cls._attributes
320
        # to store the values that are bound to this instance. Attributes store values in the dictionary
321
        # using the `python_attr_name` as the dictionary key. "Raw" (i.e. non-subclassed) MapAttribute
322
        # instances do not have any Attributes defined and instead use this dictionary to store their
323
        # collection of name-value pairs.
324
        self.attribute_values: Dict[str, Any] = {}
5✔
325
        self._set_discriminator()
5✔
326
        self._set_defaults(_user_instantiated=_user_instantiated)
5✔
327
        self._set_attributes(**attributes)
5✔
328

329
    @classmethod
5✔
330
    def _get_attributes(cls) -> Dict[str, Attribute]:
5✔
331
        """
332
        Returns the attributes of this class as a mapping from `python_attr_name` => `attribute`.
333
        """
334
        warnings.warn("`Model._get_attributes` is deprecated in favor of `Model.get_attributes` now")
×
335
        return cls.get_attributes()
×
336

337
    @classmethod
5✔
338
    def get_attributes(cls) -> Dict[str, Attribute]:
5✔
339
        """
340
        Returns the attributes of this class as a mapping from `python_attr_name` => `attribute`.
341
        """
342
        return cls._attributes
5✔
343

344
    @classmethod
5✔
345
    def _dynamo_to_python_attr(cls, dynamo_key: str) -> str:
5✔
346
        """
347
        Convert a DynamoDB attribute name to the internal Python name.
348

349
        This covers cases where an attribute name has been overridden via "attr_name".
350
        """
351
        return cls._dynamo_to_python_attrs.get(dynamo_key, dynamo_key)  # type: ignore
5✔
352

353
    @classmethod
5✔
354
    def _get_discriminator_attribute(cls) -> Optional['DiscriminatorAttribute']:
5✔
355
        return cls.get_attributes()[cls._discriminator] if cls._discriminator else None  # type: ignore
5✔
356

357
    def _set_discriminator(self) -> None:
5✔
358
        discriminator_attr = self._get_discriminator_attribute()
5✔
359
        if discriminator_attr and discriminator_attr.get_discriminator(self.__class__) is not None:
5✔
360
            setattr(self, self._discriminator, self.__class__)  # type: ignore
5✔
361

362
    def _set_defaults(self, _user_instantiated: bool = True) -> None:
5✔
363
        """
364
        Sets and fields that provide a default value
365
        """
366
        for name, attr in self.get_attributes().items():
5✔
367
            if _user_instantiated and attr.default_for_new is not None:
5✔
368
                default = attr.default_for_new
×
369
            else:
370
                default = attr.default
5✔
371
            if callable(default):
5✔
372
                value = default()
5✔
373
            else:
374
                value = default
5✔
375
            if value is not None:
5✔
376
                setattr(self, name, value)
5✔
377

378
    def _set_attributes(self, **attributes: Attribute) -> None:
5✔
379
        """
380
        Sets the attributes for this object
381
        """
382
        for attr_name, attr_value in attributes.items():
5✔
383
            if attr_name not in self.get_attributes():
5✔
384
                raise ValueError("Attribute {} specified does not exist".format(attr_name))
5✔
385
            setattr(self, attr_name, attr_value)
5✔
386

387
    def _container_serialize(self, null_check: bool = True) -> Dict[str, Dict[str, Any]]:
5✔
388
        """
389
        Serialize attribute values for DynamoDB
390
        """
391
        attribute_values: Dict[str, Dict[str, Any]] = {}
5✔
392
        for name, attr in self.get_attributes().items():
5✔
393
            value = getattr(self, name)
5✔
394
            try:
5✔
395
                if isinstance(value, MapAttribute) and not value.validate(null_check=null_check):
5✔
396
                    raise ValueError("Attribute '{}' is not correctly typed".format(name))
×
397

398
                if value is not None:
5✔
399
                    if isinstance(attr, MapAttribute):
5✔
400
                        attr_value = attr.serialize(value, null_check=null_check)
5✔
401
                    else:
402
                        attr_value = attr.serialize(value)
5✔
403
                else:
404
                    attr_value = None
5✔
405
            except AttributeNullError as e:
5✔
406
                e.prepend_path(name)
5✔
407
                raise
5✔
408

409
            if null_check and attr_value is None and not attr.null:
5✔
410
                raise AttributeNullError(name)
5✔
411

412
            if attr_value is not None:
5✔
413
                attribute_values[attr.attr_name] = {attr.attr_type: attr_value}
5✔
414
        return attribute_values
5✔
415

416
    def _container_deserialize(self, attribute_values: Dict[str, Dict[str, Any]]) -> None:
5✔
417
        """
418
        Sets attributes sent back from DynamoDB on this object
419
        """
420
        self.attribute_values = {}
5✔
421
        self._set_discriminator()
5✔
422
        self._set_defaults(_user_instantiated=False)
5✔
423
        for name, attr in self.get_attributes().items():
5✔
424
            attribute_value = attribute_values.get(attr.attr_name)
5✔
425
            if attribute_value and NULL not in attribute_value:
5✔
426
                value = attr.deserialize(attr.get_value(attribute_value))
5✔
427
                setattr(self, name, value)
5✔
428

429
    @classmethod
5✔
430
    def _update_attribute_types(cls, attribute_values: Dict[str, Dict[str, Any]]):
5✔
431
        """
432
        Update the attribute types in the attribute values dictionary to disambiguate json string and array types
433
        """
434
        for attr in cls.get_attributes().values():
5✔
435
            attribute_value = attribute_values.get(attr.attr_name)
5✔
436
            if attribute_value:
5✔
437
                AttributeContainer._coerce_attribute_type(attr.attr_type, attribute_value)
5✔
438
                if isinstance(attr, ListAttribute) and attr.element_type and LIST in attribute_value:
5✔
UNCOV
439
                    if issubclass(attr.element_type, AttributeContainer):
×
UNCOV
440
                        for element in attribute_value[LIST]:
×
UNCOV
441
                            if MAP in element:
×
UNCOV
442
                                attr.element_type._update_attribute_types(element[MAP])
×
443
                    else:
UNCOV
444
                        for element in attribute_value[LIST]:
×
UNCOV
445
                            AttributeContainer._coerce_attribute_type(attr.element_type.attr_type, element)
×
446
                if isinstance(attr, AttributeContainer) and MAP in attribute_value:
5✔
447
                    attr._update_attribute_types(attribute_value[MAP])
5✔
448

449
    @staticmethod
5✔
450
    def _coerce_attribute_type(attr_type: str, attribute_value: Dict[str, Any]):
5✔
451
        # coerce attribute types to disambiguate json string and array types
452
        if attr_type == BINARY and STRING in attribute_value:
5✔
453
            attribute_value[BINARY] = base64.b64decode(attribute_value.pop(STRING))
5✔
454
        elif attr_type == BINARY_SET and LIST in attribute_value:
5✔
455
            attribute_value[BINARY_SET] = [base64.b64decode(v[STRING]) for v in attribute_value.pop(LIST)]
5✔
456
        elif attr_type in {NUMBER_SET, STRING_SET} and LIST in attribute_value:
5✔
457
            json_type = NUMBER if attr_type == NUMBER_SET else STRING
5✔
458
            if all(next(iter(v)) == json_type for v in attribute_value[LIST]):
5✔
459
                attribute_value[attr_type] = [value[json_type] for value in attribute_value.pop(LIST)]
5✔
460

461
    @classmethod
5✔
462
    def _get_discriminator_class(cls, attribute_values: Dict[str, Dict[str, Any]]) -> Optional[Type]:
5✔
463
        discriminator_attr = cls._get_discriminator_attribute()
5✔
464
        if discriminator_attr:
5✔
465
            discriminator_attribute_value = attribute_values.get(discriminator_attr.attr_name, None)
5✔
466
            if discriminator_attribute_value:
5✔
467
                discriminator_value = discriminator_attr.get_value(discriminator_attribute_value)
5✔
468
                return discriminator_attr.deserialize(discriminator_value)
5✔
469
        return None
5✔
470

471
    @classmethod
5✔
472
    def _instantiate(cls: Type[_ACT], attribute_values: Dict[str, Dict[str, Any]]) -> _ACT:
5✔
473
        stored_cls = cls._get_discriminator_class(attribute_values)
5✔
474
        if stored_cls and not issubclass(stored_cls, cls):
5✔
475
            raise ValueError("Cannot instantiate a {} from the returned class: {}".format(
×
476
                cls.__name__, stored_cls.__name__))
477
        instance = (stored_cls or cls)(_user_instantiated=False)
5✔
478
        AttributeContainer._container_deserialize(instance, attribute_values)
5✔
479
        return instance
5✔
480

481
    def __repr__(self) -> str:
5✔
482
        fields = ', '.join(f'{k}={v!r}' for k, v in self.attribute_values.items())
5✔
483
        return f'{type(self).__name__}({fields})'
5✔
484

485

486
class DiscriminatorAttribute(Attribute[type]):
5✔
487
    attr_type = STRING
5✔
488

489
    def __init__(self, attr_name: Optional[str] = None) -> None:
5✔
490
        super().__init__(attr_name=attr_name)
5✔
491
        self._class_map: Dict[type, Any] = {}
5✔
492
        self._discriminator_map: Dict[Any, type] = {}
5✔
493

494
    def register_class(self, cls: type, discriminator: Any):
5✔
495
        discriminator = discriminator(cls) if callable(discriminator) else discriminator
5✔
496
        current_class = self._discriminator_map.get(discriminator)
5✔
497
        if current_class and current_class != cls:
5✔
498
            raise ValueError("The discriminator value '{}' is already assigned to a class: {}".format(
5✔
499
                discriminator, current_class.__name__))
500

501
        if cls not in self._class_map:
5✔
502
            self._class_map[cls] = discriminator
5✔
503

504
        self._discriminator_map[discriminator] = cls
5✔
505

506
    def get_registered_subclasses(self, cls: Type[_T]) -> List[Type[_T]]:
5✔
507
        return [k for k in self._class_map.keys() if issubclass(k, cls)]
5✔
508

509
    def get_discriminator(self, cls: type) -> Optional[Any]:
5✔
510
        return self._class_map.get(cls)
5✔
511

512
    def __set__(self, instance: Any, value: Optional[type]) -> None:
5✔
513
        if type(instance) != value:
5✔
514
            raise ValueError("The discriminator attribute must be set to the instance type: {}".format(type(instance)))
×
515
        super().__set__(instance, value)
5✔
516

517
    def serialize(self, value):
5✔
518
        """
519
        Returns the discriminator value corresponding to the given class.
520
        """
521
        return self._class_map[value]
5✔
522

523
    def deserialize(self, value):
5✔
524
        """
525
        Returns the class corresponding to the given discriminator value.
526
        """
527
        if value not in self._discriminator_map:
5✔
528
            raise ValueError("Unknown discriminator value: {}".format(value))
×
529
        return self._discriminator_map[value]
5✔
530

531

532
class BinaryAttribute(Attribute[bytes]):
5✔
533
    """
534
    An attribute containing a binary data object (:code:`bytes`).
535

536
    :param legacy_encoding: If :code:`True`, inefficient legacy encoding will be used to maintain compatibility
537
      with PynamoDB 5 and lower. Set to :code:`False` for new tables and models, and always set to :code:`False`
538
      within :class:`~pynamodb.attributes.MapAttribute`.
539

540
      For more details, see :doc:`upgrading_binary`.
541
    """
542
    attr_type = BINARY
5✔
543

544
    def __init__(self, *args: Any, legacy_encoding: bool, **kwargs: Any):
5✔
545
        super().__init__(*args, **kwargs)
5✔
546
        self.legacy_encoding = legacy_encoding
5✔
547

548
    def serialize(self, value):
5✔
549
        if self.legacy_encoding:
5✔
550
            return b64encode(value)
5✔
551
        return value
5✔
552

553
    def deserialize(self, value):
5✔
554
        if self.legacy_encoding:
5✔
555
            return b64decode(value)
5✔
556
        return value
5✔
557

558

559
class BinarySetAttribute(Attribute[Set[bytes]]):
5✔
560
    """
561
    An attribute containing a set of binary data objects (:code:`bytes`).
562

563
    :param legacy_encoding: If :code:`True`, inefficient legacy encoding will be used to maintain compatibility
564
      with PynamoDB 5 and lower. Set to :code:`False` for new tables and models, and always set to :code:`False`
565
      within :class:`~pynamodb.attributes.MapAttribute`.
566

567
      For more details, see :doc:`upgrading_binary`.
568
    """
569
    attr_type = BINARY_SET
5✔
570
    null = True
5✔
571

572
    def __init__(self, *args: Any, legacy_encoding: bool, **kwargs: Any):
5✔
573
        super().__init__(*args, **kwargs)
5✔
574
        self.legacy_encoding = legacy_encoding
5✔
575

576
    def serialize(self, value):
5✔
577
        """
578
        Returns a list of base64 encoded binary strings. Encodes empty sets as "None".
579
        """
580
        if self.legacy_encoding:
5✔
581
            return [b64encode(v) for v in value] or None
5✔
582
        return list(value) or None
5✔
583

584
    def deserialize(self, value):
5✔
585
        """
586
        Returns a set of decoded byte strings from base64 encoded values.
587
        """
588
        if self.legacy_encoding:
5✔
589
            return {b64decode(v) for v in value}
5✔
590
        return set(value)
5✔
591

592

593
class UnicodeAttribute(Attribute[str]):
5✔
594
    """
595
    A unicode attribute
596
    """
597
    attr_type = STRING
5✔
598

599

600
class UnicodeSetAttribute(Attribute[Set[str]]):
5✔
601
    """
602
    A unicode set
603
    """
604
    attr_type = STRING_SET
5✔
605
    null = True
5✔
606

607
    def serialize(self, value):
5✔
608
        """
609
        Returns a list of strings. Encodes empty sets as "None".
610
        """
611
        return list(value) or None
5✔
612

613
    def deserialize(self, value):
5✔
614
        """
615
        Returns a set from a list of strings.
616
        """
617
        return set(value)
5✔
618

619

620
class JSONAttribute(Attribute[Any]):
5✔
621
    """
622
    A JSON Attribute
623

624
    Encodes JSON to unicode internally
625
    """
626
    attr_type = STRING
5✔
627

628
    def serialize(self, value) -> Optional[str]:
5✔
629
        """
630
        Serializes JSON to unicode
631
        """
632
        if value is None:
5✔
633
            return None
5✔
634
        encoded = json.dumps(value)
5✔
635
        return encoded
5✔
636

637
    def deserialize(self, value):
5✔
638
        """
639
        Deserializes JSON
640
        """
641
        return json.loads(value, strict=False)
5✔
642

643

644
class BooleanAttribute(Attribute[bool]):
5✔
645
    """
646
    A class for boolean attributes
647
    """
648
    attr_type = BOOLEAN
5✔
649

650
    def serialize(self, value):
5✔
651
        if value is None:
5✔
652
            return None
5✔
653
        elif value:
5✔
654
            return True
5✔
655
        else:
656
            return False
5✔
657

658
    def deserialize(self, value):
5✔
659
        return bool(value)
5✔
660

661

662
class NumberAttribute(Attribute[float]):
5✔
663
    """
664
    A number attribute
665
    """
666
    attr_type = NUMBER
5✔
667

668
    def serialize(self, value):
5✔
669
        """
670
        Encode numbers as JSON
671
        """
672
        return json.dumps(value)
5✔
673

674
    def deserialize(self, value):
5✔
675
        """
676
        Decode numbers from JSON
677
        """
678
        return json.loads(value)
5✔
679

680

681
class NumberSetAttribute(Attribute[Set[float]]):
5✔
682
    """
683
    A number set attribute
684
    """
685
    attr_type = NUMBER_SET
5✔
686
    null = True
5✔
687

688
    def serialize(self, value):
5✔
689
        """
690
        Encodes a set of numbers as a JSON list. Encodes empty sets as "None".
691
        """
692
        return [json.dumps(v) for v in value] or None
5✔
693

694
    def deserialize(self, value):
5✔
695
        """
696
        Returns a set from a JSON list of numbers.
697
        """
698
        return {json.loads(v) for v in value}
5✔
699

700

701
class VersionAttribute(NumberAttribute):
5✔
702
    """
703
    A version attribute
704
    """
705
    null = True
5✔
706

707
    def __set__(self, instance, value):
5✔
708
        """
709
        Cast assigned value to int.
710
        """
711
        super().__set__(instance, int(value))
5✔
712

713
    def __get__(self, instance, owner):
5✔
714
        """
715
        Cast retrieved value to int.
716
        """
717
        val = super().__get__(instance, owner)
5✔
718
        return int(val) if isinstance(val, float) else val
5✔
719

720
    def serialize(self, value):
5✔
721
        """
722
        Cast value to int then encode as JSON
723
        """
724
        return super().serialize(int(value))
5✔
725

726
    def deserialize(self, value):
5✔
727
        """
728
        Decode numbers from JSON and cast to int.
729
        """
730
        return int(super().deserialize(value))
5✔
731

732

733
class TTLAttribute(Attribute[datetime]):
5✔
734
    """
735
    A time-to-live attribute that signifies when the item expires and can be automatically deleted.
736
    It can be assigned with a timezone-aware datetime value (for absolute expiry time)
737
    or a timedelta value (for expiry relative to the current time),
738
    but always reads as a UTC datetime value.
739
    """
740
    attr_type = NUMBER
5✔
741

742
    def _normalize(self, value):
5✔
743
        """
744
        Converts value to a UTC datetime
745
        """
746
        if value is None:
5✔
747
            return
5✔
748
        if isinstance(value, timedelta):
5✔
749
            value = int(time.time() + value.total_seconds())
5✔
750
        elif isinstance(value, datetime):
5✔
751
            if value.tzinfo is None:
5✔
752
                raise ValueError("datetime must be timezone-aware")
5✔
753
            value = calendar.timegm(value.utctimetuple())
5✔
754
        else:
755
            raise ValueError("TTLAttribute value must be a timedelta or datetime")
5✔
756
        return datetime.utcfromtimestamp(value).replace(tzinfo=timezone.utc)
5✔
757

758
    def __set__(self, instance, value):
5✔
759
        """
760
        Converts assigned values to a UTC datetime
761
        """
762
        super().__set__(instance, self._normalize(value))
5✔
763

764
    def serialize(self, value):
5✔
765
        """
766
        Serializes a datetime as a timestamp (Unix time).
767
        """
768
        if value is None:
5✔
769
            return None
5✔
770
        return json.dumps(calendar.timegm(self._normalize(value).utctimetuple()))
5✔
771

772
    def deserialize(self, value):
5✔
773
        """
774
        Deserializes a timestamp (Unix time) as a UTC datetime.
775
        """
776
        timestamp = json.loads(value)
5✔
777
        return datetime.utcfromtimestamp(timestamp).replace(tzinfo=timezone.utc)
5✔
778

779

780
class UTCDateTimeAttribute(Attribute[datetime]):
5✔
781
    """
782
    An attribute for storing a UTC Datetime
783
    """
784
    attr_type = STRING
5✔
785

786
    def serialize(self, value):
5✔
787
        """
788
        Takes a datetime object and returns a string
789
        """
790
        if value.tzinfo is None:
5✔
791
            value = value.replace(tzinfo=timezone.utc)
5✔
792
        # Padding of years under 1000 is inconsistent and depends on system strftime:
793
        # https://bugs.python.org/issue13305
794
        fmt = value.astimezone(timezone.utc).strftime(DATETIME_FORMAT).zfill(31)
5✔
795
        return fmt
5✔
796

797
    def deserialize(self, value):
5✔
798
        """
799
        Takes a UTC datetime string and returns a datetime object
800
        """
801
        return self._fast_parse_utc_date_string(value)
5✔
802

803
    @staticmethod
5✔
804
    def _fast_parse_utc_date_string(date_string: str) -> datetime:
5✔
805
        # Method to quickly parse strings formatted with '%Y-%m-%dT%H:%M:%S.%f+0000'.
806
        # This is ~5.8x faster than using strptime and 38x faster than dateutil.parser.parse.
807
        _int = int  # Hack to prevent global lookups of int, speeds up the function ~10%
5✔
808
        try:
5✔
809
            # Fix pre-1000 dates serialized on systems where strftime doesn't pad w/older PynamoDB versions.
810
            date_string = date_string.zfill(31)
5✔
811
            if (len(date_string) != 31 or date_string[4] != '-' or date_string[7] != '-'
5✔
812
                    or date_string[10] != 'T' or date_string[13] != ':' or date_string[16] != ':'
813
                    or date_string[19] != '.' or date_string[26:31] != '+0000'):
814
                raise ValueError("Datetime string '{}' does not match format '{}'".format(date_string, DATETIME_FORMAT))
5✔
815
            return datetime(
5✔
816
                _int(date_string[0:4]), _int(date_string[5:7]), _int(date_string[8:10]),
817
                _int(date_string[11:13]), _int(date_string[14:16]), _int(date_string[17:19]),
818
                _int(date_string[20:26]), timezone.utc
819
            )
820
        except (TypeError, ValueError):
5✔
821
            raise ValueError("Datetime string '{}' does not match format '{}'".format(date_string, DATETIME_FORMAT))
5✔
822

823

824
class NullAttribute(Attribute[None]):
5✔
825
    attr_type = NULL
5✔
826

827
    def serialize(self, value):
5✔
828
        return True
5✔
829

830
    def deserialize(self, value):
5✔
831
        return None
5✔
832

833

834
class MetaMapAttribute(AttributeContainerMeta):
5✔
835
    def __init__(self, name, bases, namespace, discriminator=None):
5✔
836
        super().__init__(name, bases, namespace, discriminator=discriminator)
5✔
837
        for attr_name, attr in self._attributes.items():
5✔
838
            if isinstance(attr, (BinaryAttribute, BinarySetAttribute)) and attr.legacy_encoding:
5✔
839
                raise ValueError(
5✔
840
                    "Legacy encoding is only ever needed for top-level (model) attributes. "
841
                    f"Please remove the legacy_encoding flag from the definition of '{attr_name}'."
842
                )
843

844

845
class MapAttribute(Attribute[Mapping[_KT, _VT]], AttributeContainer, metaclass=MetaMapAttribute):
5✔
846
    """
847
    A Map Attribute
848

849
    The MapAttribute class can be used to store a JSON document as "raw" name-value pairs, or
850
    it can be subclassed and the document fields represented as class attributes using Attribute instances.
851

852
    To support the ability to subclass MapAttribute and use it as an AttributeContainer, instances of
853
    MapAttribute behave differently based both on where they are instantiated and on their type.
854
    Because of this complicated behavior, a bit of an introduction is warranted.
855

856
    Models that contain a MapAttribute define its properties using a class attribute on the model.
857
    For example, below we define "MyModel" which contains a MapAttribute "my_map":
858

859
    class MyModel(Model):
860
       my_map = MapAttribute(attr_name="dynamo_name", default={})
861

862
    When instantiated in this manner (as a class attribute of an AttributeContainer class), the MapAttribute
863
    class acts as an instance of the Attribute class. The instance stores data about the attribute (in this
864
    example the dynamo name and default value), and acts as a data descriptor, storing any value bound to it
865
    on the `attribute_values` dictionary of the containing instance (in this case an instance of MyModel).
866

867
    Unlike other Attribute types, the value that gets bound to the containing instance is a new instance of
868
    MapAttribute, not an instance of the primitive type. For example, a UnicodeAttribute stores strings in
869
    the `attribute_values` of the containing instance; a MapAttribute does not store a dict but instead stores
870
    a new instance of itself. This difference in behavior is necessary when subclassing MapAttribute in order
871
    to access the Attribute data descriptors that represent the document fields.
872

873
    For example, below we redefine "MyModel" to use a subclass of MapAttribute as "my_map":
874

875
    class MyMapAttribute(MapAttribute):
876
        my_internal_map = MapAttribute()
877

878
    class MyModel(Model):
879
        my_map = MyMapAttribute(attr_name="dynamo_name", default = {})
880

881
    In order to set the value of my_internal_map on an instance of MyModel we need the bound value for "my_map"
882
    to be an instance of MapAttribute so that it acts as a data descriptor:
883

884
    MyModel().my_map.my_internal_map = {'foo': 'bar'}
885

886
    That is the attribute access of "my_map" must return a MyMapAttribute instance and not a dict.
887

888
    When an instance is used in this manner (bound to an instance of an AttributeContainer class),
889
    the MapAttribute class acts as an AttributeContainer class itself. The instance does not store data
890
    about the attribute, and does not act as a data descriptor. The instance stores name-value pairs in its
891
    internal `attribute_values` dictionary.
892

893
    Thus while MapAttribute multiply inherits from Attribute and AttributeContainer, a MapAttribute instance
894
    does not behave as both an Attribute AND an AttributeContainer. Rather an instance of MapAttribute behaves
895
    EITHER as an Attribute OR as an AttributeContainer, depending on where it was instantiated.
896

897
    So, how do we create this dichotomous behavior?
898
    All MapAttribute instances are initialized as AttributeContainers only. During construction of
899
    AttributeContainer classes (subclasses of MapAttribute and Model), any instances that are class attributes
900
    are transformed from AttributeContainers to Attributes (via the `_make_attribute` method call).
901
    """
902
    attr_type = MAP
5✔
903

904
    attribute_args = getfullargspec(Attribute.__init__).args[1:]
5✔
905

906
    def __init__(self, **attributes):
5✔
907
        # Store the kwargs used by Attribute.__init__ in case `_make_attribute` is called.
908
        self.attribute_kwargs = {arg: attributes.pop(arg) for arg in self.attribute_args if arg in attributes}
5✔
909

910
        # Assume all instances should behave like an AttributeContainer. Instances that are intended to be
911
        # used as Attributes will be transformed during creation of the containing class.
912
        # Because of this do not use MRO or cooperative multiple inheritance, call the parent class directly.
913
        AttributeContainer.__init__(self, **attributes)
5✔
914

915
        # It is possible that attributes names can collide with argument names of Attribute.__init__.
916
        # Assume that this is the case if any of the following are true:
917
        #   - the user passed in other attributes that did not match any argument names
918
        #   - this is a "raw" (i.e. non-subclassed) MapAttribute instance and attempting to store the attributes
919
        #     cannot raise a ValueError (if this assumption is wrong, calling `_make_attribute` removes them)
920
        #   - the names of all attributes in self.attribute_kwargs match attributes defined on the class
921
        if self.attribute_kwargs and (
5✔
922
                attributes or self.is_raw() or all(arg in self.get_attributes() for arg in self.attribute_kwargs)):
923
            self._set_attributes(**self.attribute_kwargs)
5✔
924

925
    def _is_attribute_container(self):
5✔
926
        # Determine if this instance is being used as an AttributeContainer or an Attribute.
927
        # AttributeContainer instances have an internal `attribute_values` dictionary that is removed
928
        # by the `_make_attribute` call during initialization of the containing class.
929
        return 'attribute_values' in self.__dict__
5✔
930

931
    def _make_attribute(self):
5✔
932
        # WARNING! This function is only intended to be called from the __set_name__ function.
933
        if not self._is_attribute_container():
5✔
934
            raise AssertionError("MapAttribute._make_attribute called on an initialized instance")
×
935
        # During initialization the kwargs were stored in `attribute_kwargs`. Remove them and re-initialize the class.
936
        kwargs = self.attribute_kwargs
5✔
937
        del self.attribute_kwargs
5✔
938
        del self.attribute_values
5✔
939
        Attribute.__init__(self, **kwargs)
5✔
940
        for name, attr in self.get_attributes().items():
5✔
941
            # Set a local attribute with the same name that shadows the class attribute.
942
            # Because attr is a data descriptor and the attribute already exists on the class,
943
            # we have to store the local copy directly into __dict__ to prevent calling attr.__set__.
944
            # Use deepcopy so that `attr_path` and any local attributes are also copied.
945
            self.__dict__[name] = deepcopy(attr)
5✔
946

947
    def _update_attribute_paths(self, path_segment):
5✔
948
        # WARNING! This function is only intended to be called from the __set_name__ function.
949
        if self._is_attribute_container():
5✔
950
            raise AssertionError("MapAttribute._update_attribute_paths called before MapAttribute._make_attribute")
×
951
        for name in self.get_attributes().keys():
5✔
952
            local_attr = self.__dict__[name]
5✔
953
            local_attr.attr_path.insert(0, path_segment)
5✔
954
            if isinstance(local_attr, MapAttribute):
5✔
955
                local_attr._update_attribute_paths(path_segment)
5✔
956

957
    def __eq__(self, other: Any) -> 'Comparison':  # type: ignore[override]
5✔
958
        if self._is_attribute_container():
5✔
959
            return NotImplemented
5✔
960
        return Attribute.__eq__(self, other)
5✔
961

962
    def __ne__(self, other: Any) -> 'Comparison':  # type: ignore[override]
5✔
963
        if self._is_attribute_container():
×
964
            return NotImplemented
×
965
        return Attribute.__ne__(self, other)
×
966

967
    def __lt__(self, other: Any) -> 'Comparison':
5✔
968
        if self._is_attribute_container():
×
969
            return NotImplemented
×
970
        return Attribute.__lt__(self, other)
×
971

972
    def __le__(self, other: Any) -> 'Comparison':
5✔
973
        if self._is_attribute_container():
×
974
            return NotImplemented
×
975
        return Attribute.__le__(self, other)
×
976

977
    def __gt__(self, other: Any) -> 'Comparison':
5✔
978
        if self._is_attribute_container():
×
979
            return NotImplemented
×
980
        return Attribute.__gt__(self, other)
×
981

982
    def __ge__(self, other: Any) -> 'Comparison':
5✔
983
        if self._is_attribute_container():
×
984
            return NotImplemented
×
985
        return Attribute.__ge__(self, other)
×
986

987
    def __iter__(self):
5✔
988
        if self._is_attribute_container():
5✔
989
            return iter(self.attribute_values)
5✔
990
        return super().__iter__()
×
991

992
    def __getitem__(self, item: _KT) -> _VT:  # type: ignore
5✔
993
        if self._is_attribute_container():
5✔
994
            return self.attribute_values[item]
5✔
995
        # If this instance is being used as an Attribute, treat item access like the map dereference operator.
996
        # This provides equivalence between DynamoDB's nested attribute access for map elements (MyMap.nestedField)
997
        # and Python's item access for dictionaries (MyMap['nestedField']).
998
        if item in self.get_attributes():
5✔
999
            return getattr(self, item)
5✔
1000
        elif self.is_raw():
5✔
1001
            return Path(self.attr_path + [str(item)])  # type: ignore
5✔
1002
        else:
1003
            raise AttributeError("'{}' has no attribute '{}'".format(self.__class__.__name__, item))
5✔
1004

1005
    def __setitem__(self, item, value):
5✔
1006
        if not self._is_attribute_container():
5✔
1007
            raise TypeError("'{}' object does not support item assignment".format(self.__class__.__name__))
×
1008
        if item in self.get_attributes():
5✔
1009
            setattr(self, item, value)
×
1010
        elif self.is_raw():
5✔
1011
            self.attribute_values[item] = value
5✔
1012
        else:
1013
            raise AttributeError("'{}' has no attribute '{}'".format(self.__class__.__name__, item))
×
1014

1015
    def __getattr__(self, attr: str) -> _VT:
5✔
1016
        # This should only be called for "raw" (i.e. non-subclassed) MapAttribute instances.
1017
        # MapAttribute subclasses should access attributes via the Attribute descriptors.
1018
        if self.is_raw() and self._is_attribute_container():
5✔
1019
            try:
5✔
1020
                return self.attribute_values[attr]
5✔
1021
            except KeyError:
5✔
1022
                pass
5✔
1023
        raise AttributeError("'{}' has no attribute '{}'".format(self.__class__.__name__, attr))
5✔
1024

1025
    @overload  # type: ignore
5✔
1026
    def __get__(self: _A, instance: None, owner: Any) -> _A: ...
5✔
1027
    @overload
5✔
1028
    def __get__(self: _MT, instance: Any, owner: Any) -> _MT: ...
5✔
1029
    def __get__(self: _A, instance: Any, owner: Any) -> Union[_A, _T]:
5✔
1030
        # just for typing
1031
        return super().__get__(instance, owner)
5✔
1032

1033
    def __setattr__(self, name, value):
5✔
1034
        # "Raw" (i.e. non-subclassed) instances set their name-value pairs in the `attribute_values` dictionary.
1035
        # MapAttribute subclasses should set attributes via the Attribute descriptors.
1036
        if self.is_raw() and self._is_attribute_container():
5✔
1037
            self.attribute_values[name] = value
5✔
1038
        else:
1039
            object.__setattr__(self, name, value)
5✔
1040

1041
    def __set__(self, instance: Any, value: Union[None, 'MapAttribute[_KT, _VT]', Mapping[_KT, _VT]]):
5✔
1042
        if isinstance(value, collections.abc.Mapping):
5✔
1043
            value = type(self)(**value)  # type: ignore
5✔
1044
        return super().__set__(instance, value)  # type: ignore
5✔
1045

1046
    def __set_name__(self, owner: Type[Any], name: str) -> None:
5✔
1047
        if issubclass(owner, AttributeContainer):
5✔
1048
            # MapAttribute instances that are class attributes of an AttributeContainer class
1049
            # should behave like an Attribute instance and not an AttributeContainer instance.
1050
            self._make_attribute()
5✔
1051

1052
            super().__set_name__(owner, name)
5✔
1053

1054
            # To support creating expressions from nested attributes, MapAttribute instances
1055
            # store local copies of the attributes in cls._attributes with `attr_path` set.
1056
            # Prepend the `attr_path` lists with the dynamo attribute name.
1057
            self._update_attribute_paths(self.attr_name)
5✔
1058

1059
    def _set_attributes(self, **attrs):
5✔
1060
        """
1061
        Sets the attributes for this object
1062
        """
1063
        if self.is_raw():
5✔
1064
            for name, value in attrs.items():
5✔
1065
                setattr(self, name, value)
5✔
1066
        else:
1067
            super()._set_attributes(**attrs)
5✔
1068

1069
    def is_correctly_typed(self, key, attr, *, null_check: bool = True):
5✔
1070
        can_be_null = attr.null or not null_check
5✔
1071
        value = getattr(self, key)
5✔
1072
        if can_be_null and value is None:
5✔
1073
            return True
5✔
1074
        if getattr(self, key) is None:
5✔
1075
            raise AttributeNullError(key)
5✔
1076
        return True  # TODO: check that the actual type of `value` meets requirements of `attr`
5✔
1077

1078
    def validate(self, *, null_check: bool = False):
5✔
1079
        return all(self.is_correctly_typed(k, v, null_check=null_check)
5✔
1080
                   for k, v in self.get_attributes().items())
1081

1082
    def _serialize_undeclared_attributes(self, values, container: Dict):
5✔
1083
        # Continue to serialize NULL values in "raw" map attributes for backwards compatibility.
1084
        # This special case behavior for "raw" attributes should be removed in the future.
1085
        for attr_name in values:
5✔
1086
            if attr_name not in self.get_attributes():
5✔
1087
                v = values[attr_name]
5✔
1088
                attr_class = _get_class_for_serialize(v)
5✔
1089
                attr_type = attr_class.attr_type
5✔
1090
                attr_value = attr_class.serialize(v)
5✔
1091
                if attr_value is None:
5✔
1092
                    # When attribute values serialize to "None" (e.g. empty sets) we store {"NULL": True} in DynamoDB.
1093
                    attr_type = NULL
×
1094
                    attr_value = True
×
1095
                container[attr_name] = {attr_type: attr_value}
5✔
1096
        return container
5✔
1097

1098
    def serialize(self, values, *, null_check: bool = True):
5✔
1099
        if not self.is_raw():
5✔
1100
            # This is a subclassed MapAttribute that acts as an AttributeContainer.
1101
            # Serialize the values based on the attributes in the class.
1102

1103
            if not isinstance(values, type(self)):
5✔
1104
                # Copy the values onto an instance of the class for serialization.
1105
                instance = type(self)()
5✔
1106
                instance.attribute_values = {}  # clear any defaults
5✔
1107
                for name in values:
5✔
1108
                    if name in self.get_attributes():
5✔
1109
                        setattr(instance, name, values[name])
5✔
1110
                values = instance
5✔
1111

1112
            return AttributeContainer._container_serialize(values, null_check=null_check)
5✔
1113

1114
        # For a "raw" MapAttribute all fields are undeclared
1115
        return self._serialize_undeclared_attributes(values, {})
5✔
1116

1117
    def deserialize(self, values):
5✔
1118
        """
1119
        Decode as a dict.
1120
        """
1121
        if not self.is_raw():
5✔
1122
            # If this is a subclass of a MapAttribute (i.e typed), instantiate an instance
1123
            return self._instantiate(values)
5✔
1124

1125
        return {
5✔
1126
            k: DESERIALIZE_CLASS_MAP[attr_type].deserialize(attr_value)
1127
            for k, v in values.items() for attr_type, attr_value in v.items()
1128
        }
1129

1130
    @classmethod
5✔
1131
    def is_raw(cls):
3✔
1132
        return cls == MapAttribute
5✔
1133

1134
    def as_dict(self):
5✔
1135
        result = {}
5✔
1136
        for key, value in self.attribute_values.items():
5✔
1137
            result[key] = value.as_dict() if isinstance(value, MapAttribute) else value
5✔
1138
        return result
5✔
1139

1140

1141
class DynamicMapAttribute(MapAttribute):
5✔
1142
    """
1143
    A map attribute that supports declaring attributes (like an AttributeContainer) but will also store
1144
    any other values that are set on it (like a raw MapAttribute).
1145

1146
    >>> class MyDynamicMapAttribute(DynamicMapAttribute):
1147
    >>>     a_date_time = UTCDateTimeAttribute()  # raw map attributes cannot serialize/deserialize datetime values
1148
    >>>
1149
    >>> dynamic_map = MyDynamicMapAttribute()
1150
    >>> dynamic_map.a_date_time = datetime.utcnow()
1151
    >>> dynamic_map.a_number = 5
1152
    >>> dynamic_map.serialize()  # {'a_date_time': {'S': 'xxx'}, 'a_number': {'N': '5'}}
1153
    """
1154

1155
    def __setattr__(self, name, value):
5✔
1156
        # Set attributes via the Attribute descriptor if it exists.
1157
        if name in self.get_attributes():
5✔
1158
            object.__setattr__(self, name, value)
5✔
1159
        else:
1160
            super().__setattr__(name, value)
5✔
1161

1162
    def serialize(self, values, *, null_check: bool = True):
5✔
1163
        if not isinstance(values, type(self)):
5✔
1164
            # Copy the values onto an instance of the class for serialization.
1165
            instance = type(self)()
×
1166
            instance.attribute_values = {}  # clear any defaults
×
1167
            instance._set_attributes(**values)
×
1168
            values = instance
×
1169

1170
        # this serializes the class defined attributes.
1171
        # we do this first because we have type checks that validate the data
1172
        rval = AttributeContainer._container_serialize(values, null_check=null_check)
5✔
1173

1174
        # this serializes the dynamically defined attributes
1175
        # we have no real type safety here so we have to dynamically construct the type to write to dynamo
1176
        self._serialize_undeclared_attributes(values, rval)
5✔
1177

1178
        return rval
5✔
1179

1180
    def deserialize(self, values):
5✔
1181
        # this deserializes the class defined attributes
1182
        # we do this first so that the we populate the defined object attributes fields properly with type safety
1183
        instance = self._instantiate(values)
5✔
1184
        # this deserializes the dynamically defined attributes
1185
        for attr_name, value in values.items():
5✔
1186
            if instance._dynamo_to_python_attr(attr_name) not in instance.get_attributes():
5✔
1187
                attr_type, attr_value = next(iter(value.items()))
5✔
1188
                instance[attr_name] = DESERIALIZE_CLASS_MAP[attr_type].deserialize(attr_value)
5✔
1189
        return instance
5✔
1190

1191
    @classmethod
5✔
1192
    def is_raw(cls):
3✔
1193
        # All subclasses of DynamicMapAttribute should be treated like "raw" map attributes.
1194
        return True
5✔
1195

1196

1197
def _get_class_for_serialize(value: Any) -> Attribute:
5✔
1198
    if value is None:
5✔
1199
        return NullAttribute()
5✔
1200
    if isinstance(value, MapAttribute):
5✔
1201
        return value
5✔
1202
    if isinstance(value, set):
5✔
1203
        set_types = {type(v) for v in value}
5✔
1204
        if not set_types:
5✔
1205
            raise ValueError("Cannot serialize empty set")
5✔
1206
        if set_types == {str}:
5✔
1207
            return UnicodeSetAttribute()
5✔
1208
        if set_types <= {int, float}:
5✔
1209
            return NumberSetAttribute()
5✔
1210
        if set_types == {bytes}:
5✔
1211
            return BinarySetAttribute(legacy_encoding=False)
5✔
1212
        raise ValueError(f"Cannot serialize set consisting of types: {', '.join(sorted(map(repr, set_types)))}")
5✔
1213

1214
    value_type = type(value)
5✔
1215
    attr = SERIALIZE_CLASS_MAP.get(value_type)
5✔
1216
    if attr is None:
5✔
1217
        raise ValueError(f"Unsupported value type '{value_type}'")
×
1218
    return attr
5✔
1219

1220

1221
class ListAttribute(Generic[_T], Attribute[List[_T]]):
5✔
1222
    attr_type = LIST
5✔
1223
    element_type: Optional[Type[Attribute]] = None
5✔
1224

1225
    def __init__(
5✔
1226
        self,
1227
        hash_key: bool = False,
1228
        range_key: bool = False,
1229
        null: Optional[bool] = None,
1230
        default: Optional[Union[Any, Callable[..., Any]]] = None,
1231
        attr_name: Optional[str] = None,
1232
        of: Optional[Type[_T]] = None,
1233
    ) -> None:
1234
        super().__init__(
5✔
1235
            hash_key=hash_key,
1236
            range_key=range_key,
1237
            null=null,
1238
            default=default,
1239
            attr_name=attr_name,
1240
        )
1241
        if of:
5✔
1242
            if not issubclass(of, Attribute):
5✔
1243
                raise ValueError("'of' must be a subclass of Attribute")
×
1244
            self.element_type = of
5✔
1245

1246
    def serialize(self, values):
5✔
1247
        """
1248
        Encode the given list of objects into a list of AttributeValue types.
1249
        """
1250
        rval = []
5✔
1251
        for idx, v in enumerate(values):
5✔
1252
            attr_class = self._get_serialize_class(v)
5✔
1253
            if self.element_type and v is not None and not isinstance(attr_class, self.element_type):
5✔
1254
                raise ValueError("List elements must be of type: {}".format(self.element_type.__name__))
5✔
1255
            attr_type = attr_class.attr_type
5✔
1256
            try:
5✔
1257
                attr_value = attr_class.serialize(v)
5✔
1258
            except AttributeNullError as e:
5✔
1259
                e.prepend_path(f'[{idx}]')
5✔
1260
                raise
5✔
1261
            if attr_value is None:
5✔
1262
                # When attribute values serialize to "None" (e.g. empty sets) we store {"NULL": True} in DynamoDB.
1263
                attr_type = NULL
5✔
1264
                attr_value = True
5✔
1265
            rval.append({attr_type: attr_value})
5✔
1266
        return rval
5✔
1267

1268
    def deserialize(self, values):
5✔
1269
        """
1270
        Decode from list of AttributeValue types.
1271
        """
1272
        if self.element_type:
5✔
1273
            element_attr: Attribute
1274
            if issubclass(self.element_type, (BinaryAttribute, BinarySetAttribute)):
5✔
1275
                element_attr = self.element_type(legacy_encoding=False)
5✔
1276
            else:
1277
                element_attr = self.element_type()
5✔
1278
                if isinstance(element_attr, MapAttribute):
5✔
1279
                    element_attr._make_attribute()  # ensure attr_name exists
5✔
1280
            deserialized_lst = []
5✔
1281
            for idx, attribute_value in enumerate(values):
5✔
1282
                value = None
5✔
1283
                if NULL not in attribute_value:
5✔
1284
                    # set attr_name in case `get_value` raises an exception
1285
                    element_attr.attr_name = f'{self.attr_name}[{idx}]' if self.attr_name else f'[{idx}]'
5✔
1286
                    value = element_attr.deserialize(element_attr.get_value(attribute_value))
5✔
1287
                deserialized_lst.append(value)
5✔
1288
            return deserialized_lst
5✔
1289

1290
        return [
5✔
1291
            DESERIALIZE_CLASS_MAP[attr_type].deserialize(attr_value)
1292
            for v in values for attr_type, attr_value in v.items()
1293
        ]
1294

1295
    def __getitem__(self, idx: int) -> Path:  # type: ignore
5✔
1296
        if not isinstance(idx, int):
5✔
1297
            raise TypeError("list indices must be integers, not {}".format(type(idx).__name__))
×
1298

1299
        if self.element_type:
5✔
1300
            # If this instance is typed, return a properly configured attribute on list element access.
1301
            element_attr = self.element_type()
5✔
1302
            if isinstance(element_attr, MapAttribute):
5✔
1303
                element_attr._make_attribute()
5✔
1304
            element_attr.attr_path = list(self.attr_path)  # copy the document path before indexing last element
5✔
1305
            element_attr.attr_name = '{}[{}]'.format(element_attr.attr_name, idx)
5✔
1306
            if isinstance(element_attr, MapAttribute):
5✔
1307
                for path_segment in reversed(element_attr.attr_path):
5✔
1308
                    element_attr._update_attribute_paths(path_segment)
5✔
1309
            return element_attr  # type: ignore
5✔
1310

1311
        return super().__getitem__(idx)
5✔
1312

1313
    def _get_serialize_class(self, value):
5✔
1314
        if value is None:
5✔
1315
            return NullAttribute()
5✔
1316
        if isinstance(value, Attribute):
5✔
1317
            return value
5✔
1318
        if self.element_type:
5✔
1319
            if issubclass(self.element_type, (BinaryAttribute, BinarySetAttribute)):
5✔
1320
                return self.element_type(legacy_encoding=False)
5✔
1321
            return self.element_type()
5✔
1322
        return _get_class_for_serialize(value)
5✔
1323

1324

1325
DESERIALIZE_CLASS_MAP: Dict[str, Attribute] = {
5✔
1326
    BINARY: BinaryAttribute(legacy_encoding=False),
1327
    BINARY_SET: BinarySetAttribute(legacy_encoding=False),
1328
    BOOLEAN: BooleanAttribute(),
1329
    LIST: ListAttribute(),
1330
    MAP: MapAttribute(),
1331
    NULL: NullAttribute(),
1332
    NUMBER: NumberAttribute(),
1333
    NUMBER_SET: NumberSetAttribute(),
1334
    STRING: UnicodeAttribute(),
1335
    STRING_SET: UnicodeSetAttribute()
1336
}
1337

1338
SERIALIZE_CLASS_MAP: Mapping[type, Attribute] = {
5✔
1339
    dict: MapAttribute(),
1340
    list: ListAttribute(),
1341
    bool: BooleanAttribute(),
1342
    float: NumberAttribute(),
1343
    int: NumberAttribute(),
1344
    str: UnicodeAttribute(),
1345
    bytes: BinaryAttribute(legacy_encoding=False),
1346
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc