Skip to content

Commit

Permalink
custom json (d)encoders, fix #714
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelcolvin committed Sep 20, 2019
1 parent cccf39e commit 1734708
Show file tree
Hide file tree
Showing 9 changed files with 58 additions and 25 deletions.
1 change: 1 addition & 0 deletions changes/714-samuelcolvin.rst
@@ -0,0 +1 @@
Allow custom JSON decoding and encoding via ``json_loads`` and ``json_dumps`` ``Config`` properties.
15 changes: 8 additions & 7 deletions docs/index.rst
Expand Up @@ -91,19 +91,20 @@ To test if *pydantic* is compiled run::
import pydantic
print('compiled:', pydantic.compiled)

If you want *pydantic* to parse json faster you can add `ujson <https://pypi.python.org/pypi/ujson>`_
as an optional dependency. Similarly *pydantic's* email validation relies on
`email-validator <https://github.com/JoshData/python-email-validator>`_ ::
If you require email validation you can add `email-validator <https://github.com/JoshData/python-email-validator>`_
as an optional dependency. Similarly, use of ``Literal`` relies on
`typing-extensions <https://pypi.org/project/typing-extensions/>`_::

pip install pydantic[ujson]
# or
pip install pydantic[email]
# or
pip install pydantic[typing_extensions]
# or just
pip install pydantic[ujson,email]
pip install pydantic[email,typing_extensions]

Of course you can also install these requirements manually with ``pip install ...``.

Pydantic is also available on `conda <https://www.anaconda.com>`_ under the `conda-forge <https://conda-forge.org>`_ channel::
Pydantic is also available on `conda <https://www.anaconda.com>`_ under the `conda-forge <https://conda-forge.org>`_
channel::

conda install pydantic -c conda-forge

Expand Down
3 changes: 1 addition & 2 deletions pydantic/env_settings.py
@@ -1,4 +1,3 @@
import json
import os
from typing import Any, Dict, Optional, cast

Expand Down Expand Up @@ -47,7 +46,7 @@ def _build_environ(self) -> Dict[str, Optional[str]]:
if env_val:
if field.is_complex():
try:
env_val = json.loads(env_val)
env_val = self.__config__.json_loads(env_val) # type: ignore
except ValueError as e:
raise SettingsError(f'error parsing JSON for "{env_name}"') from e
d[field.alias] = env_val
Expand Down
13 changes: 10 additions & 3 deletions pydantic/main.py
Expand Up @@ -68,6 +68,8 @@ class BaseConfig:
alias_generator: Optional[Callable[[str], str]] = None
keep_untouched: Tuple[type, ...] = ()
schema_extra: Dict[str, Any] = {}
json_loads: Callable[[str], Any] = json.loads
json_dumps: Callable[..., str] = json.dumps

@classmethod
def get_field_schema(cls, name: str) -> Dict[str, str]:
Expand Down Expand Up @@ -307,7 +309,7 @@ def json(
data = self.dict(include=include, exclude=exclude, by_alias=by_alias, skip_defaults=skip_defaults)
if self._custom_root_type:
data = data['__root__']
return json.dumps(data, default=encoder, **dumps_kwargs)
return self.__config__.json_dumps(data, default=encoder, **dumps_kwargs)

@classmethod
def parse_obj(cls: Type['Model'], obj: Any) -> 'Model':
Expand All @@ -334,7 +336,12 @@ def parse_raw(
) -> 'Model':
try:
obj = load_str_bytes(
b, proto=proto, content_type=content_type, encoding=encoding, allow_pickle=allow_pickle
b,
proto=proto,
content_type=content_type,
encoding=encoding,
allow_pickle=allow_pickle,
json_loads=cls.__config__.json_loads,
)
except (ValueError, TypeError, UnicodeDecodeError) as e:
raise ValidationError([ErrorWrapper(e, loc='__obj__')], cls)
Expand Down Expand Up @@ -437,7 +444,7 @@ def schema(cls, by_alias: bool = True) -> 'DictStrAny':
def schema_json(cls, *, by_alias: bool = True, **dumps_kwargs: Any) -> str:
from .json import pydantic_encoder

return json.dumps(cls.schema(by_alias=by_alias), default=pydantic_encoder, **dumps_kwargs)
return cls.__config__.json_dumps(cls.schema(by_alias=by_alias), default=pydantic_encoder, **dumps_kwargs)

@classmethod
def __get_validators__(cls) -> 'CallableGenerator':
Expand Down
18 changes: 10 additions & 8 deletions pydantic/parse.py
@@ -1,23 +1,25 @@
import json
import pickle
from enum import Enum
from pathlib import Path
from typing import Any, Union
from typing import Any, Callable, Union

from .types import StrBytes

try:
import ujson as json
except ImportError:
import json # type: ignore


class Protocol(str, Enum):
json = 'json'
pickle = 'pickle'


def load_str_bytes(
b: StrBytes, *, content_type: str = None, encoding: str = 'utf8', proto: Protocol = None, allow_pickle: bool = False
b: StrBytes,
*,
content_type: str = None,
encoding: str = 'utf8',
proto: Protocol = None,
allow_pickle: bool = False,
json_loads: Callable[[str], Any] = json.loads,
) -> Any:
if proto is None and content_type:
if content_type.endswith(('json', 'javascript')):
Expand All @@ -32,7 +34,7 @@ def load_str_bytes(
if proto == Protocol.json:
if isinstance(b, bytes):
b = b.decode(encoding)
return json.loads(b)
return json_loads(b)
elif proto == Protocol.pickle:
if not allow_pickle:
raise RuntimeError('Trying to decode with pickle with allow_pickle=False')
Expand Down
5 changes: 2 additions & 3 deletions pydantic/validators.py
@@ -1,4 +1,3 @@
import json
import re
import sys
from collections import OrderedDict
Expand Down Expand Up @@ -424,9 +423,9 @@ def constr_strip_whitespace(v: 'StrBytes', field: 'Field', config: 'BaseConfig')
return v


def validate_json(v: Any) -> Any:
def validate_json(v: Any, config: 'BaseConfig') -> Any:
try:
return json.loads(v)
return config.json_loads(v) # type: ignore
except ValueError:
raise errors.JsonError()
except TypeError:
Expand Down
1 change: 0 additions & 1 deletion requirements.txt
Expand Up @@ -2,7 +2,6 @@
-r docs/requirements.txt
-r tests/requirements.txt

ujson==1.35
email-validator==1.0.4
dataclasses==0.6; python_version < '3.7'
typing-extensions==3.7.4
1 change: 0 additions & 1 deletion setup.py
Expand Up @@ -99,7 +99,6 @@ def extra(self):
'dataclasses>=0.6;python_version<"3.7"'
],
extras_require={
'ujson': ['ujson>=1.35'],
'email': ['email-validator>=1.0.3'],
'typing_extensions': ['typing-extensions>=3.7.2']
},
Expand Down
26 changes: 26 additions & 0 deletions tests/test_json.py
Expand Up @@ -167,3 +167,29 @@ class Model(BaseModel):
__root__: List[str]

assert Model(__root__=['a', 'b']).json() == '["a", "b"]'


def test_custom_decode_encode():
load_calls, dump_calls = 0, 0

def custom_loads(s):
nonlocal load_calls
load_calls += 1
return json.loads(s.strip('$'))

def custom_dumps(s, default=None, **kwargs):
nonlocal dump_calls
dump_calls += 1
return json.dumps(s, default=default, indent=2)

class Model(BaseModel):
a: int
b: str

class Config:
json_loads = custom_loads
json_dumps = custom_dumps

m = Model.parse_raw('${"a": 1, "b": "foo"}$$')
assert m.dict() == {'a': 1, 'b': 'foo'}
assert m.json() == '{\n "a": 1,\n "b": "foo"\n}'

0 comments on commit 1734708

Please sign in to comment.