Skip to content

Commit 4929aee

Browse files
authored
Add support for JSON serialization. (#857)
1 parent 245a9be commit 4929aee

File tree

6 files changed

+261
-8
lines changed

6 files changed

+261
-8
lines changed

docs/release_notes.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Release Notes
44
Unreleased
55
----------
66
* The ``IndexMeta`` class has been removed. Now ``type(Index) == type``.
7+
* JSON serialization support (``Model.to_json`` and ``Model.from_json``) has been added.
78

89

910
v5.1.0

pynamodb/attributes.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,20 @@
1717
from typing import TYPE_CHECKING
1818

1919
from pynamodb._compat import GenericMeta
20-
from pynamodb.constants import (
21-
BINARY, BINARY_SET, BOOLEAN, DATETIME_FORMAT, DEFAULT_ENCODING,
22-
LIST, MAP, NULL, NUMBER, NUMBER_SET, STRING, STRING_SET
23-
)
24-
from pynamodb.exceptions import AttributeDeserializationError, AttributeNullError
20+
from pynamodb.constants import BINARY
21+
from pynamodb.constants import BINARY_SET
22+
from pynamodb.constants import BOOLEAN
23+
from pynamodb.constants import DATETIME_FORMAT
24+
from pynamodb.constants import DEFAULT_ENCODING
25+
from pynamodb.constants import LIST
26+
from pynamodb.constants import MAP
27+
from pynamodb.constants import NULL
28+
from pynamodb.constants import NUMBER
29+
from pynamodb.constants import NUMBER_SET
30+
from pynamodb.constants import STRING
31+
from pynamodb.constants import STRING_SET
32+
from pynamodb.exceptions import AttributeDeserializationError
33+
from pynamodb.exceptions import AttributeNullError
2534
from pynamodb.expressions.operand import Path
2635

2736

@@ -369,6 +378,36 @@ def _container_deserialize(self, attribute_values: Dict[str, Dict[str, Any]]) ->
369378
value = attr.deserialize(attr.get_value(attribute_value))
370379
setattr(self, name, value)
371380

381+
@classmethod
382+
def _update_attribute_types(cls, attribute_values: Dict[str, Dict[str, Any]]):
383+
"""
384+
Update the attribute types in the attribute values dictionary to disambiguate json string and array types
385+
"""
386+
for attr in cls.get_attributes().values():
387+
attribute_value = attribute_values.get(attr.attr_name)
388+
if attribute_value:
389+
AttributeContainer._coerce_attribute_type(attr.attr_type, attribute_value)
390+
if isinstance(attr, ListAttribute) and attr.element_type and LIST in attribute_value:
391+
if issubclass(attr.element_type, AttributeContainer):
392+
for element in attribute_value[LIST]:
393+
if MAP in element:
394+
attr.element_type._update_attribute_types(element[MAP])
395+
else:
396+
for element in attribute_value[LIST]:
397+
AttributeContainer._coerce_attribute_type(attr.element_type.attr_type, element)
398+
if isinstance(attr, AttributeContainer) and MAP in attribute_value:
399+
attr._update_attribute_types(attribute_value[MAP])
400+
401+
@staticmethod
402+
def _coerce_attribute_type(attr_type: str, attribute_value: Dict[str, Any]):
403+
# coerce attribute types to disambiguate json string and array types
404+
if attr_type == BINARY and STRING in attribute_value:
405+
attribute_value[BINARY] = attribute_value.pop(STRING)
406+
if attr_type in {BINARY_SET, NUMBER_SET, STRING_SET} and LIST in attribute_value:
407+
json_type = NUMBER if attr_type == NUMBER_SET else STRING
408+
if all(next(iter(v)) == json_type for v in attribute_value[LIST]):
409+
attribute_value[attr_type] = [value[json_type] for value in attribute_value.pop(LIST)]
410+
372411
@classmethod
373412
def _get_discriminator_class(cls, attribute_values: Dict[str, Dict[str, Any]]) -> Optional[Type]:
374413
discriminator_attr = cls._get_discriminator_attribute()
@@ -1174,4 +1213,5 @@ def _get_serialize_class(self, value):
11741213
float: NumberAttribute(),
11751214
int: NumberAttribute(),
11761215
str: UnicodeAttribute(),
1216+
bytes: BinaryAttribute(),
11771217
}

pynamodb/models.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
DynamoDB Models for PynamoDB
33
"""
4+
import json
45
import random
56
import time
67
import logging
@@ -54,6 +55,8 @@
5455
COUNT, ITEM_COUNT, KEY, UNPROCESSED_ITEMS, STREAM_VIEW_TYPE,
5556
STREAM_SPECIFICATION, STREAM_ENABLED, BILLING_MODE, PAY_PER_REQUEST_BILLING_MODE, TAGS
5657
)
58+
from pynamodb.util import attribute_value_to_json
59+
from pynamodb.util import json_to_attribute_value
5760

5861
_T = TypeVar('_T', bound='Model')
5962
_KeyType = Any
@@ -846,7 +849,6 @@ def update_ttl(cls, ignore_update_ttl_errors: bool) -> None:
846849
raise
847850

848851
# Private API below
849-
850852
@classmethod
851853
def _get_schema(cls) -> Dict[str, Any]:
852854
"""
@@ -1110,6 +1112,14 @@ def deserialize(self, attribute_values: Dict[str, Dict[str, Any]]) -> None:
11101112
"""
11111113
return self._container_deserialize(attribute_values=attribute_values)
11121114

1115+
def to_json(self) -> str:
1116+
return json.dumps({k: attribute_value_to_json(v) for k, v in self.serialize().items()})
1117+
1118+
def from_json(self, s: str) -> None:
1119+
attribute_values = {k: json_to_attribute_value(v) for k, v in json.loads(s).items()}
1120+
self._update_attribute_types(attribute_values)
1121+
self.deserialize(attribute_values)
1122+
11131123

11141124
class _ModelFuture(Generic[_T]):
11151125
"""

pynamodb/util.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""
2+
Utils
3+
"""
4+
import json
5+
from typing import Any
6+
from typing import Dict
7+
8+
from pynamodb.constants import BINARY
9+
from pynamodb.constants import BINARY_SET
10+
from pynamodb.constants import BOOLEAN
11+
from pynamodb.constants import LIST
12+
from pynamodb.constants import MAP
13+
from pynamodb.constants import NULL
14+
from pynamodb.constants import NUMBER
15+
from pynamodb.constants import NUMBER_SET
16+
from pynamodb.constants import STRING
17+
from pynamodb.constants import STRING_SET
18+
19+
20+
def attribute_value_to_json(attribute_value: Dict[str, Any]) -> Any:
21+
attr_type, attr_value = next(iter(attribute_value.items()))
22+
if attr_type == LIST:
23+
return [attribute_value_to_json(v) for v in attr_value]
24+
if attr_type == MAP:
25+
return {k: attribute_value_to_json(v) for k, v in attr_value.items()}
26+
if attr_type == NULL:
27+
return None
28+
if attr_type in {BINARY, BINARY_SET, BOOLEAN, STRING, STRING_SET}:
29+
return attr_value
30+
if attr_type == NUMBER:
31+
return json.loads(attr_value)
32+
if attr_type == NUMBER_SET:
33+
return [json.loads(v) for v in attr_value]
34+
raise ValueError("Unknown attribute type: {}".format(attr_type))
35+
36+
37+
def json_to_attribute_value(value: Any) -> Dict[str, Any]:
38+
if value is None:
39+
return {NULL: True}
40+
if value is True or value is False:
41+
return {BOOLEAN: value}
42+
if isinstance(value, (int, float)):
43+
return {NUMBER: json.dumps(value)}
44+
if isinstance(value, str):
45+
return {STRING: value}
46+
if isinstance(value, list):
47+
return {LIST: [json_to_attribute_value(v) for v in value]}
48+
if isinstance(value, dict):
49+
return {MAP: {k: json_to_attribute_value(v) for k, v in value.items()}}
50+
raise ValueError("Unknown value type: {}".format(type(value).__name__))

tests/test_attributes.py

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""
22
pynamodb attributes tests
33
"""
4+
import calendar
45
import json
56

67
from base64 import b64encode
@@ -13,10 +14,10 @@
1314

1415
from pynamodb.attributes import (
1516
BinarySetAttribute, BinaryAttribute, DynamicMapAttribute, NumberSetAttribute, NumberAttribute,
16-
UnicodeAttribute, UnicodeSetAttribute, UTCDateTimeAttribute, BooleanAttribute, MapAttribute,
17+
UnicodeAttribute, UnicodeSetAttribute, UTCDateTimeAttribute, BooleanAttribute, MapAttribute, NullAttribute,
1718
ListAttribute, JSONAttribute, TTLAttribute, VersionAttribute)
1819
from pynamodb.constants import (
19-
DEFAULT_ENCODING, NUMBER, STRING, STRING_SET, NUMBER_SET, BINARY_SET,
20+
DATETIME_FORMAT, DEFAULT_ENCODING, NUMBER, STRING, STRING_SET, NUMBER_SET, BINARY_SET,
2021
BINARY, BOOLEAN,
2122
)
2223
from pynamodb.models import Model
@@ -39,6 +40,7 @@ class Meta:
3940
json_attr = JSONAttribute()
4041
map_attr = MapAttribute()
4142
ttl_attr = TTLAttribute()
43+
null_attr = NullAttribute(null=True)
4244

4345

4446
class CustomAttrMap(MapAttribute):
@@ -1062,3 +1064,119 @@ def test_deserialize(self):
10621064
assert attr.deserialize('1') == 1
10631065
assert attr.deserialize('3.141') == 3
10641066
assert attr.deserialize('12345678909876543211234234324234') == 12345678909876543211234234324234
1067+
1068+
1069+
class TestAttributeContainer:
1070+
def test_to_json(self):
1071+
now = datetime.now(tz=timezone.utc)
1072+
now_formatted = now.strftime(DATETIME_FORMAT)
1073+
now_unix_ts = calendar.timegm(now.utctimetuple())
1074+
test_model = AttributeTestModel()
1075+
test_model.binary_attr = b'foo'
1076+
test_model.binary_set_attr = {b'bar'}
1077+
test_model.number_attr = 1
1078+
test_model.number_set_attr = {0, 0.5, 1}
1079+
test_model.unicode_attr = 'foo'
1080+
test_model.unicode_set_attr = {'baz'}
1081+
test_model.datetime_attr = now
1082+
test_model.bool_attr = True
1083+
test_model.json_attr = {'foo': 'bar'}
1084+
test_model.map_attr = {'foo': 'bar'}
1085+
test_model.ttl_attr = now
1086+
test_model.null_attr = True
1087+
assert test_model.to_json() == (
1088+
'{'
1089+
'"binary_attr": "Zm9v", '
1090+
'"binary_set_attr": ["YmFy"], '
1091+
'"bool_attr": true, '
1092+
'"datetime_attr": "' + now_formatted + '", '
1093+
'"json_attr": "{\\"foo\\": \\"bar\\"}", '
1094+
'"map_attr": {"foo": "bar"}, '
1095+
'"null_attr": null, '
1096+
'"number_attr": 1, '
1097+
'"number_set_attr": [0, 0.5, 1], '
1098+
'"ttl_attr": ' + str(now_unix_ts) + ', '
1099+
'"unicode_attr": "foo", '
1100+
'"unicode_set_attr": ["baz"]'
1101+
'}')
1102+
1103+
def test_from_json(self):
1104+
now = datetime.now(tz=timezone.utc)
1105+
now_formatted = now.strftime(DATETIME_FORMAT)
1106+
now_unix_ts = calendar.timegm(now.utctimetuple())
1107+
json_string = (
1108+
'{'
1109+
'"binary_attr": "Zm9v", '
1110+
'"binary_set_attr": ["YmFy"], '
1111+
'"bool_attr": true, '
1112+
'"datetime_attr": "' + now_formatted + '", '
1113+
'"json_attr": "{\\"foo\\": \\"bar\\"}", '
1114+
'"map_attr": {"foo": "bar"}, '
1115+
'"null_attr": null, '
1116+
'"number_attr": 1, '
1117+
'"number_set_attr": [0, 0.5, 1], '
1118+
'"ttl_attr": ' + str(now_unix_ts) + ', '
1119+
'"unicode_attr": "foo", '
1120+
'"unicode_set_attr": ["baz"]'
1121+
'}')
1122+
test_model = AttributeTestModel()
1123+
test_model.from_json(json_string)
1124+
assert test_model.binary_attr == b'foo'
1125+
assert test_model.binary_set_attr == {b'bar'}
1126+
assert test_model.number_attr == 1
1127+
assert test_model.number_set_attr == {0, 0.5, 1}
1128+
assert test_model.unicode_attr == 'foo'
1129+
assert test_model.unicode_set_attr == {'baz'}
1130+
assert test_model.datetime_attr == now
1131+
assert test_model.bool_attr is True
1132+
assert test_model.json_attr == {'foo': 'bar'}
1133+
assert test_model.map_attr.foo == 'bar'
1134+
assert test_model.ttl_attr == now.replace(microsecond=0)
1135+
assert test_model.null_attr is None
1136+
1137+
def test_to_json_complex(self):
1138+
class MyMap(MapAttribute):
1139+
foo = UnicodeSetAttribute(attr_name='bar')
1140+
1141+
class ListTestModel(Model):
1142+
class Meta:
1143+
host = 'http://localhost:8000'
1144+
table_name = 'test'
1145+
unicode_attr = UnicodeAttribute(hash_key=True)
1146+
list_attr = ListAttribute(of=NumberSetAttribute)
1147+
list_map_attr = ListAttribute(of=MyMap)
1148+
1149+
list_test_model = ListTestModel()
1150+
list_test_model.unicode_attr = 'foo'
1151+
list_test_model.list_attr = [{0, 1, 2}]
1152+
list_test_model.list_map_attr = [MyMap(foo={'baz'})]
1153+
assert list_test_model.to_json() == (
1154+
'{'
1155+
'"list_attr": [[0, 1, 2]], '
1156+
'"list_map_attr": [{"bar": ["baz"]}], '
1157+
'"unicode_attr": "foo"'
1158+
'}')
1159+
1160+
def test_from_json_complex(self):
1161+
class MyMap(MapAttribute):
1162+
foo = UnicodeSetAttribute(attr_name='bar')
1163+
1164+
class ListTestModel(Model):
1165+
class Meta:
1166+
host = 'http://localhost:8000'
1167+
table_name = 'test'
1168+
unicode_attr = UnicodeAttribute(hash_key=True)
1169+
list_attr = ListAttribute(of=NumberSetAttribute)
1170+
list_map_attr = ListAttribute(of=MyMap)
1171+
1172+
json_string = (
1173+
'{'
1174+
'"list_attr": [[0, 1, 2]], '
1175+
'"list_map_attr": [{"bar": ["baz"]}], '
1176+
'"unicode_attr": "foo"'
1177+
'}')
1178+
list_test_model = ListTestModel()
1179+
list_test_model.from_json(json_string)
1180+
assert list_test_model.unicode_attr == 'foo'
1181+
assert list_test_model.list_attr == [{0, 1, 2}]
1182+
assert list_test_model.list_map_attr[0].foo == {'baz'}

tests/test_model.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2528,6 +2528,40 @@ class Meta:
25282528
with self.assertRaises(AttributeError):
25292529
MissingTableNameModel.exists()
25302530

2531+
def test_to_json(self):
2532+
"""
2533+
Model.to_json
2534+
"""
2535+
user = UserModel()
2536+
user.custom_user_name = 'foo'
2537+
user.user_id = 'bar'
2538+
user.picture = base64.b64decode(BINARY_ATTR_DATA)
2539+
user.zip_code = 88030
2540+
json_user = json.loads(user.to_json())
2541+
self.assertEqual(json_user['user_name'], user.custom_user_name) # uses custom attribute name
2542+
self.assertEqual(json_user['user_id'], user.user_id)
2543+
self.assertEqual(json_user['picture'], BINARY_ATTR_DATA)
2544+
self.assertEqual(json_user['zip_code'], user.zip_code)
2545+
self.assertEqual(json_user['email'], 'needs_email') # set to default value
2546+
2547+
def test_from_json(self):
2548+
"""
2549+
Model.from_json
2550+
"""
2551+
json_user = {
2552+
'user_name': 'foo',
2553+
'user_id': 'bar',
2554+
'picture': BINARY_ATTR_DATA,
2555+
'zip_code': 88030,
2556+
}
2557+
user = UserModel()
2558+
user.from_json(json.dumps(json_user))
2559+
self.assertEqual(user.custom_user_name, json_user['user_name']) # uses custom attribute name
2560+
self.assertEqual(user.user_id, json_user['user_id'])
2561+
self.assertEqual(user.picture, base64.b64decode(json_user['picture']))
2562+
self.assertEqual(user.zip_code, json_user['zip_code'])
2563+
self.assertEqual(user.email, 'needs_email') # set to default value
2564+
25312565
def _get_office_employee(self):
25322566
justin = Person(
25332567
fname='Justin',

0 commit comments

Comments
 (0)