Skip to content

Commit 14f81c7

Browse files
authored
Simplify map attribute deserialization. (#839)
1 parent 29179cd commit 14f81c7

File tree

5 files changed

+47
-94
lines changed

5 files changed

+47
-94
lines changed

pynamodb/attributes.py

Lines changed: 34 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@
1313
from dateutil.tz import tzutc
1414
from inspect import getfullargspec
1515
from inspect import getmembers
16-
from typing import Any, Callable, Dict, Generic, List, Mapping, Optional, Text, TypeVar, Type, Union, Set, overload
16+
from typing import Any, Callable, Dict, Generic, List, Mapping, Optional, TypeVar, Type, Union, Set, overload
1717
from typing import TYPE_CHECKING
1818

1919
from pynamodb._compat import GenericMeta
2020
from pynamodb.constants import (
2121
BINARY, BINARY_SET, BOOLEAN, DATETIME_FORMAT, DEFAULT_ENCODING,
2222
LIST, MAP, NULL, NUMBER, NUMBER_SET, STRING, STRING_SET
2323
)
24+
from pynamodb.exceptions import AttributeDeserializationError
2425
from pynamodb.expressions.operand import Path
2526

2627

@@ -71,12 +72,11 @@ def __init__(
7172
self.is_hash_key = hash_key
7273
self.is_range_key = range_key
7374

74-
# AttributeContainerMeta._initialize_attributes will ensure this is a
75-
# string
75+
# AttributeContainerMeta._initialize_attributes will ensure this is a string
7676
self.attr_path: List[str] = [attr_name] # type: ignore
7777

7878
@property
79-
def attr_name(self) -> Optional[str]:
79+
def attr_name(self) -> str:
8080
return self.attr_path[-1]
8181

8282
@attr_name.setter
@@ -120,8 +120,10 @@ def deserialize(self, value: Any) -> Any:
120120
"""
121121
return value
122122

123-
def get_value(self, value: Any) -> Any:
124-
return value.get(self.attr_type)
123+
def get_value(self, value: Dict[str, Any]) -> Any:
124+
if self.attr_type not in value:
125+
raise AttributeDeserializationError(self.attr_name, self.attr_type)
126+
return value[self.attr_type]
125127

126128
def __iter__(self):
127129
# Because we define __getitem__ below for condition expression support
@@ -278,7 +280,7 @@ def get_attributes(cls) -> Dict[str, Attribute]:
278280
return cls._attributes # type: ignore
279281

280282
@classmethod
281-
def _dynamo_to_python_attr(cls, dynamo_key: str) -> Optional[str]:
283+
def _dynamo_to_python_attr(cls, dynamo_key: str) -> str:
282284
"""
283285
Convert a DynamoDB attribute name to the internal Python name.
284286
@@ -311,6 +313,18 @@ def _set_attributes(self, **attributes: Attribute) -> None:
311313
raise ValueError("Attribute {} specified does not exist".format(attr_name))
312314
setattr(self, attr_name, attr_value)
313315

316+
def _deserialize(self, attribute_values: Dict[str, Dict[str, Any]]) -> None:
317+
"""
318+
Sets attributes sent back from DynamoDB on this object
319+
"""
320+
self.attribute_values = {}
321+
self._set_defaults(_user_instantiated=False)
322+
for name, attr in self.get_attributes().items():
323+
attribute_value = attribute_values.get(attr.attr_name)
324+
if attribute_value and NULL not in attribute_value:
325+
value = attr.deserialize(attr.get_value(attribute_value))
326+
setattr(self, name, value)
327+
314328
def __eq__(self, other: Any) -> bool:
315329
# This is required so that MapAttribute can call this method.
316330
return self is other
@@ -803,8 +817,7 @@ def serialize(self, values):
803817
continue
804818

805819
# If this is a subclassed MapAttribute, there may be an alternate attr name
806-
attr = self.get_attributes().get(k)
807-
attr_name = attr.attr_name if attr else k
820+
attr_name = attr_class.attr_name if not self.is_raw() else k
808821

809822
serialized = attr_class.serialize(v)
810823
if self._should_skip(serialized):
@@ -819,23 +832,17 @@ def deserialize(self, values):
819832
"""
820833
Decode as a dict.
821834
"""
822-
deserialized_dict: Dict[str, Any] = dict()
823-
for k in values:
824-
v = values[k]
825-
attr_value = _get_value_for_deserialize(v)
826-
key = self._dynamo_to_python_attr(k)
827-
attr_class = self._get_deserialize_class(key, v)
828-
if key is None or attr_class is None:
829-
continue
830-
deserialized_value = None
831-
if attr_value is not None:
832-
deserialized_value = attr_class.deserialize(attr_value)
833-
834-
deserialized_dict[key] = deserialized_value
835-
836-
# If this is a subclass of a MapAttribute (i.e typed), instantiate an instance
837835
if not self.is_raw():
838-
return type(self)(**deserialized_dict)
836+
# If this is a subclass of a MapAttribute (i.e typed), instantiate an instance
837+
instance = type(self)()
838+
instance._deserialize(values)
839+
return instance
840+
841+
deserialized_dict: Dict[str, Any] = dict()
842+
for k, v in values.items():
843+
attr_type, attr_value = next(iter(v.items()))
844+
attr_class = DESERIALIZE_CLASS_MAP[attr_type]
845+
deserialized_dict[k] = attr_class.deserialize(attr_value)
839846
return deserialized_dict
840847

841848
@classmethod
@@ -850,7 +857,7 @@ def as_dict(self):
850857

851858
def _should_skip(self, value):
852859
# Continue to serialize NULL values in "raw" map attributes for backwards compatibility.
853-
# This special case behavior for "raw" attribtues should be removed in the future.
860+
# This special case behavior for "raw" attributes should be removed in the future.
854861
return not self.is_raw() and value is None
855862

856863
@classmethod
@@ -859,32 +866,12 @@ def _get_serialize_class(cls, key, value):
859866
return cls.get_attributes().get(key)
860867
return _get_class_for_serialize(value)
861868

862-
@classmethod
863-
def _get_deserialize_class(cls, key, value):
864-
if not cls.is_raw():
865-
return cls.get_attributes().get(key)
866-
return _get_class_for_deserialize(value)
867-
868-
869-
def _get_value_for_deserialize(value):
870-
key = next(iter(value.keys()))
871-
if key == NULL:
872-
return None
873-
return value[key]
874-
875-
876-
def _get_class_for_deserialize(value):
877-
value_type = next(iter(value.keys()))
878-
if value_type not in DESERIALIZE_CLASS_MAP:
879-
raise ValueError('Unknown value: ' + str(value))
880-
return DESERIALIZE_CLASS_MAP[value_type]
881-
882869

883870
def _get_class_for_serialize(value):
884871
if value is None:
885872
return NullAttribute()
886873
if isinstance(value, MapAttribute):
887-
return type(value)()
874+
return value
888875
value_type = type(value)
889876
if value_type not in SERIALIZE_CLASS_MAP:
890877
raise ValueError('Unknown value: {}'.format(value_type))

pynamodb/exceptions.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ class AttributeDeserializationError(TypeError):
122122
"""
123123
Raised when attribute type is invalid
124124
"""
125-
def __init__(self, attr_name: str):
126-
msg = "Deserialization error on `{}`".format(attr_name)
125+
def __init__(self, attr_name: str, attr_type: str):
126+
msg = "Cannot deserialize '{}' attribute from type: {}".format(attr_name, attr_type)
127127
super(AttributeDeserializationError, self).__init__(msg)
128128

129129

pynamodb/models.py

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
Tuple, Union, cast
1212

1313
from pynamodb.expressions.update import Action
14-
from pynamodb.exceptions import DoesNotExist, TableDoesNotExist, TableError, InvalidStateError, PutError, AttributeDeserializationError
14+
from pynamodb.exceptions import DoesNotExist, TableDoesNotExist, TableError, InvalidStateError, PutError
1515
from pynamodb.attributes import (
1616
Attribute, AttributeContainer, AttributeContainerMeta, MapAttribute, TTLAttribute, VersionAttribute
1717
)
@@ -546,16 +546,9 @@ def from_raw_data(cls: Type[_T], data: Dict[str, Any]) -> _T:
546546
if data is None:
547547
raise ValueError("Received no data to construct object")
548548

549-
attributes: Dict[str, Any] = {}
550-
for name, value in data.items():
551-
attr_name = cls._dynamo_to_python_attr(name)
552-
attr = cls.get_attributes().get(attr_name, None) # type: ignore
553-
if attr:
554-
try:
555-
attributes[attr_name] = attr.deserialize(attr.get_value(value)) # type: ignore
556-
except TypeError as e:
557-
raise AttributeDeserializationError(attr_name=attr_name) from e # type: ignore
558-
return cls(_user_instantiated=False, **attributes)
549+
model = cls(_user_instantiated=False)
550+
model._deserialize(data)
551+
return model
559552

560553
@classmethod
561554
def count(
@@ -1114,20 +1107,6 @@ def _get_connection(cls) -> TableConnection:
11141107
aws_session_token=cls.Meta.aws_session_token)
11151108
return cls._connection
11161109

1117-
def _deserialize(self, attrs):
1118-
"""
1119-
Sets attributes sent back from DynamoDB on this object
1120-
1121-
:param attrs: A dictionary of attributes to update this item with.
1122-
"""
1123-
for name, attr in self.get_attributes().items():
1124-
value = attrs.get(attr.attr_name, None)
1125-
if value is not None:
1126-
value = value.get(attr.attr_type, None)
1127-
if value is not None:
1128-
value = attr.deserialize(value)
1129-
setattr(self, name, value)
1130-
11311110
def _serialize(self, attr_map=False, null_check=True) -> Dict[str, Any]:
11321111
"""
11331112
Serializes all model attributes for use with DynamoDB

tests/data.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@
462462
'N': '31'
463463
},
464464
'is_dude': {
465-
'N': '1'
465+
'BOOL': True
466466
}
467467
}
468468
},
@@ -641,7 +641,7 @@
641641
'N': '31'
642642
},
643643
'is_dude': {
644-
'N': '1'
644+
'BOOL': True
645645
}
646646
}
647647
},
@@ -677,7 +677,7 @@
677677
'N': '30'
678678
},
679679
'is_dude': {
680-
'N': '1'
680+
'BOOL': True
681681
}
682682
}
683683
},
@@ -713,7 +713,7 @@
713713
'N': '32'
714714
},
715715
'is_dude': {
716-
'N': '1'
716+
'BOOL': True
717717
}
718718
}
719719
},
@@ -749,7 +749,7 @@
749749
'N': '30'
750750
},
751751
'is_dude': {
752-
'N': '0'
752+
'BOOL': False
753753
}
754754
}
755755
},
@@ -926,7 +926,7 @@
926926
'N': '31'
927927
},
928928
'is_dude': {
929-
'N': '1'
929+
'BOOL': True
930930
}
931931
}
932932

tests/test_attributes.py

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from pynamodb.attributes import (
1717
BinarySetAttribute, BinaryAttribute, NumberSetAttribute, NumberAttribute,
1818
UnicodeAttribute, UnicodeSetAttribute, UTCDateTimeAttribute, BooleanAttribute, MapAttribute,
19-
ListAttribute, JSONAttribute, TTLAttribute, _get_value_for_deserialize, _fast_parse_utc_datestring,
19+
ListAttribute, JSONAttribute, TTLAttribute, _fast_parse_utc_datestring,
2020
VersionAttribute)
2121
from pynamodb.constants import (
2222
DATETIME_FORMAT, DEFAULT_ENCODING, NUMBER, STRING, STRING_SET, NUMBER_SET, BINARY_SET,
@@ -890,19 +890,6 @@ class MyModel(Model):
890890
assert mid_map_b_map_attr.attr_path == ['dyn_out_map', 'mid_map_b', 'dyn_in_map_b', 'dyn_map_attr']
891891

892892

893-
class TestValueDeserialize:
894-
def test__get_value_for_deserialize(self):
895-
expected = '3'
896-
data = {'N': '3'}
897-
actual = _get_value_for_deserialize(data)
898-
assert expected == actual
899-
900-
def test__get_value_for_deserialize_null(self):
901-
data = {'NULL': 'True'}
902-
actual = _get_value_for_deserialize(data)
903-
assert actual is None
904-
905-
906893
class TestListAttribute:
907894

908895
def test_untyped_list(self):

0 commit comments

Comments
 (0)