Skip to content

Commit 37364a0

Browse files
authored
implement JSON serialisation (pydantic#210)
* implement JSON serialisation, fix pydantic#133 * documenting JSON serialisation * fix coverage
1 parent 596ddac commit 37364a0

File tree

10 files changed

+182
-18
lines changed

10 files changed

+182
-18
lines changed

HISTORY.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ v0.11.0 (2018-06-28)
99
* **breaking change**: remove msgpack parsing #201
1010
* add ``FilePath`` and ``DirectoryPath`` types #10
1111
* model schema generation #190
12+
* JSON serialisation of models and schemas #133
1213

1314
v0.10.0 (2018-06-11)
1415
....................

docs/examples/copy_values.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,13 @@ class FooBarModel(BaseModel):
1414
m = FooBarModel(banana=3.14, foo='hello', bar={'whatever': 123})
1515

1616
print(m.dict())
17+
# (returns a dictionary)
1718
# > {'banana': 3.14, 'foo': 'hello', 'bar': {'whatever': 123}}
1819

20+
print(m.json())
21+
# (returns a str)
22+
# > {"banana": 3.14, "foo": "hello", "bar": {"whatever": 123}}
23+
1924
print(m.dict(include={'foo', 'bar'}))
2025
# > {'foo': 'hello', 'bar': {'whatever': 123}}
2126

docs/examples/schema1.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"type": "object",
33
"title": "Main",
4+
"description": "This is the description of the main model",
45
"properties": {
56
"foo_bar": {
67
"type": "object",
@@ -37,6 +38,5 @@
3738
"default": 42,
3839
"description": "this is the value of snap"
3940
}
40-
},
41-
"description": "This is the description of the main model"
41+
}
4242
}

docs/examples/schema1.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,11 @@ class MainModel(BaseModel):
3131
class Config:
3232
title = 'Main'
3333

34-
print(json.dumps(MainModel.schema(), indent=2))
34+
debug(MainModel.schema())
35+
# > {
36+
# 'type': 'object',
37+
# 'title': 'Main',
38+
# 'properties': {
39+
# 'foo_bar': {
40+
# ...
41+
print(MainModel.schema_json(indent=2))

docs/index.rst

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,8 @@ Outputs:
206206

207207
(This script is complete, it should run "as is")
208208

209+
`schema` will return a dict of the schema, while `schema_json` will return a JSON representation of that.
210+
209211
"submodels" are recursively included in the schema.
210212

211213
The ``description`` for models is taken from the docstring of the class.
@@ -227,7 +229,7 @@ Instead of using ``Schema``, the ``fields`` property of :ref:`the Config class <
227229
to set all the arguments above except ``default``.
228230

229231
The schema is generated by default using aliases as keys, it can also be generated using model
230-
property names not aliases with ``MainModel.Schema(by_alias=False)``.
232+
property names not aliases with ``MainModel.schema/schema_json(by_alias=False)``.
231233

232234
Error Handling
233235
..............
@@ -455,17 +457,18 @@ a model.
455457
Trying to change ``a`` caused an error and it remains unchanged, however the dict ``b`` is mutable and the
456458
immutability of ``foobar`` doesn't stop being changed.
457459

458-
Copying and Dictionary Serialization
459-
....................................
460+
Copying and Serialization
461+
.........................
460462

461-
The ``dict`` function returns a dict containing the attributes of a model. Sub-models are recursively
462-
converted to dicts.
463+
The ``dict`` function returns a dictionary containing the attributes of a model. Sub-models are recursively
464+
converted to dicts. The ``json`` method will serialise a model to JSON using ``dict``.
463465

464466
While ``copy`` allows models to be duplicated, this is particularly useful for immutable models.
465467

466-
Both ``dict`` and ``copy`` take the optional ``include`` and ``exclude`` keyword arguments to control which attributes
467-
are returned or copied, respectively. ``copy`` accepts an extra keyword argument, ``update``, which accepts a ``dict``
468-
mapping attributes to new values that will be applied as the model is duplicated.
468+
469+
``dict``, ``json`` and ``copy`` all take the optional ``include`` and ``exclude`` keyword arguments to control
470+
which attributes are returned or copied, respectively. ``copy`` accepts an extra keyword argument, ``update``,
471+
which accepts a ``dict`` mapping attributes to new values that will be applied as the model is duplicated.
469472

470473
.. literalinclude:: examples/copy_values.py
471474

pydantic/json.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import datetime
2+
from decimal import Decimal
3+
from enum import Enum
4+
from types import GeneratorType
5+
from uuid import UUID
6+
7+
from .main import BaseModel
8+
9+
__all__ = ['pydantic_encoder']
10+
11+
12+
def isoformat(o):
13+
return o.isoformat()
14+
15+
16+
ENCODERS_BY_TYPE = {
17+
UUID: str,
18+
datetime.datetime: isoformat,
19+
datetime.date: isoformat,
20+
datetime.time: isoformat,
21+
set: list,
22+
frozenset: list,
23+
GeneratorType: list,
24+
bytes: lambda o: o.decode(),
25+
Decimal: float,
26+
}
27+
28+
29+
def pydantic_encoder(obj):
30+
if isinstance(obj, BaseModel):
31+
return obj.dict()
32+
elif isinstance(obj, Enum):
33+
return obj.value
34+
35+
try:
36+
encoder = ENCODERS_BY_TYPE[type(obj)]
37+
except KeyError:
38+
raise TypeError(f"Object of type '{obj.__class__.__name__}' is not JSON serializable")
39+
else:
40+
return encoder(obj)

pydantic/main.py

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import warnings
23
from abc import ABCMeta
34
from copy import deepcopy
@@ -181,13 +182,21 @@ def __setstate__(self, state):
181182

182183
def dict(self, *, include: Set[str]=None, exclude: Set[str]=set()) -> Dict[str, Any]:
183184
"""
184-
Get a dict of the values processed by the model, optionally specifying which fields to include or exclude.
185+
Generate a dictionary representation of the model, optionally specifying which fields to include or exclude.
185186
"""
186187
return {
187188
k: v for k, v in self
188189
if k not in exclude and (not include or k in include)
189190
}
190191

192+
def json(self, *, include: Set[str]=None, exclude: Set[str]=set(), **dumps_kwargs) -> str:
193+
"""
194+
Generate a JSON representation of the model, `include` and `exclude` arguments as per `dict()`. Other arguments
195+
as per `json.dumps()`.
196+
"""
197+
from .json import pydantic_encoder
198+
return json.dumps(self.dict(include=include, exclude=exclude), default=pydantic_encoder, **dumps_kwargs)
199+
191200
@classmethod
192201
def parse_obj(cls, obj):
193202
if not isinstance(obj, dict):
@@ -253,24 +262,31 @@ def fields(self):
253262
return self.__fields__
254263

255264
@classmethod
256-
def schema(cls, by_alias=True):
265+
def schema(cls, by_alias=True) -> Dict[str, Any]:
257266
cached = cls._schema_cache.get(by_alias)
258267
if cached is not None:
259268
return cached
260-
if by_alias:
261-
props = {f.alias: f.schema(by_alias) for f in cls.__fields__.values()}
262-
else:
263-
props = {k: f.schema(by_alias) for k, f in cls.__fields__.items()}
269+
264270
s = {
265271
'type': 'object',
266272
'title': cls.__config__.title or cls.__name__,
267-
'properties': props,
268273
}
269274
if cls.__doc__:
270275
s['description'] = clean_docstring(cls.__doc__)
276+
277+
if by_alias:
278+
s['properties'] = {f.alias: f.schema(by_alias) for f in cls.__fields__.values()}
279+
else:
280+
s['properties'] = {k: f.schema(by_alias) for k, f in cls.__fields__.items()}
281+
271282
cls._schema_cache[by_alias] = s
272283
return s
273284

285+
@classmethod
286+
def schema_json(cls, *, by_alias=True, **dumps_kwargs) -> str:
287+
from .json import pydantic_encoder
288+
return json.dumps(cls.schema(by_alias=by_alias), default=pydantic_encoder, **dumps_kwargs)
289+
274290
@classmethod
275291
def get_validators(cls):
276292
yield dict_validator

tests/test_json.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import datetime
2+
import json
3+
from decimal import Decimal
4+
from enum import Enum
5+
from uuid import UUID
6+
7+
import pytest
8+
9+
from pydantic import BaseModel, create_model
10+
from pydantic.json import pydantic_encoder
11+
12+
13+
class MyEnum(Enum):
14+
foo = 'bar'
15+
snap = 'crackle'
16+
17+
18+
@pytest.mark.parametrize('input,output', [
19+
(UUID('ebcdab58-6eb8-46fb-a190-d07a33e9eac8'), '"ebcdab58-6eb8-46fb-a190-d07a33e9eac8"'),
20+
(datetime.datetime(2032, 1, 1, 1, 1), '"2032-01-01T01:01:00"'),
21+
(datetime.datetime(2032, 1, 1, 1, 1, tzinfo=datetime.timezone.utc), '"2032-01-01T01:01:00+00:00"'),
22+
(datetime.datetime(2032, 1, 1), '"2032-01-01T00:00:00"'),
23+
(datetime.time(12, 34, 56), '"12:34:56"'),
24+
({1, 2, 3}, '[1, 2, 3]'),
25+
(frozenset([1, 2, 3]), '[1, 2, 3]'),
26+
((v for v in range(4)), '[0, 1, 2, 3]'),
27+
(b'this is bytes', '"this is bytes"'),
28+
(Decimal('12.34'), '12.34'),
29+
(create_model('BarModel', a='b', c='d')(), '{"a": "b", "c": "d"}'),
30+
(MyEnum.foo, '"bar"')
31+
])
32+
def test_encoding(input, output):
33+
assert json.dumps(input, default=pydantic_encoder) == output
34+
35+
36+
def test_model_encoding():
37+
class ModelA(BaseModel):
38+
x: int
39+
y: str
40+
41+
class Model(BaseModel):
42+
a: float
43+
b: bytes
44+
c: Decimal
45+
d: ModelA
46+
47+
m = Model(a=10.2, b='foobar', c=10.2, d={'x': 123, 'y': '123'})
48+
assert m.dict() == {'a': 10.2, 'b': b'foobar', 'c': Decimal('10.2'), 'd': {'x': 123, 'y': '123'}}
49+
assert m.json() == '{"a": 10.2, "b": "foobar", "c": 10.2, "d": {"x": 123, "y": "123"}}'
50+
assert m.json(exclude={'b'}) == '{"a": 10.2, "c": 10.2, "d": {"x": 123, "y": "123"}}'
51+
52+
53+
def test_invalid_model():
54+
class Foo:
55+
pass
56+
57+
with pytest.raises(TypeError):
58+
json.dumps(Foo, default=pydantic_encoder)

tests/test_main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ def test_ultra_simple_repr():
5656
assert repr(m) == '<UltraSimpleModel a=10.2 b=10>'
5757
assert repr(m.fields['a']) == "<Field(a type=float required)>"
5858
assert dict(m) == {'a': 10.2, 'b': 10}
59+
assert m.dict() == {'a': 10.2, 'b': 10}
60+
assert m.json() == '{"a": 10.2, "b": 10}'
5961

6062

6163
def test_str_truncate():

tests/test_schema.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import json
2+
from decimal import Decimal
13
from enum import Enum, IntEnum
24

35
import pytest
@@ -191,3 +193,33 @@ class Model(BaseModel):
191193
},
192194
},
193195
}
196+
197+
198+
def test_json_schema():
199+
class Model(BaseModel):
200+
a = b'foobar'
201+
b = Decimal('12.34')
202+
203+
with pytest.raises(TypeError):
204+
json.dumps(Model.schema())
205+
206+
assert Model.schema_json(indent=2) == (
207+
'{\n'
208+
' "type": "object",\n'
209+
' "title": "Model",\n'
210+
' "properties": {\n'
211+
' "a": {\n'
212+
' "type": "bytes",\n'
213+
' "title": "A",\n'
214+
' "required": false,\n'
215+
' "default": "foobar"\n'
216+
' },\n'
217+
' "b": {\n'
218+
' "type": "Decimal",\n'
219+
' "title": "B",\n'
220+
' "required": false,\n'
221+
' "default": 12.34\n'
222+
' }\n'
223+
' }\n'
224+
'}'
225+
)

0 commit comments

Comments
 (0)