Skip to content

Commit

Permalink
Add nested json encoding (#3941)
Browse files Browse the repository at this point in the history
* add nested json encoding

* fix timezones

* add changes doc

* split tests and rename example

* Update changes/3941-lilyminium.md

Co-authored-by: Samuel Colvin <samcolvin@gmail.com>

* split tests into functions

Co-authored-by: Samuel Colvin <samcolvin@gmail.com>
Co-authored-by: Samuel Colvin <s@muelcolvin.com>
  • Loading branch information
3 people committed Aug 8, 2022
1 parent 8f388e1 commit b42fae0
Show file tree
Hide file tree
Showing 5 changed files with 145 additions and 0 deletions.
1 change: 1 addition & 0 deletions changes/3941-lilyminium.md
@@ -0,0 +1 @@
add `use_nested_encoders` keyword argument to `BaseModel.json` to allow encoding nested subclasses with encoders specified in the inner classes
41 changes: 41 additions & 0 deletions docs/examples/exporting_models_json_nested_encoders.py
@@ -0,0 +1,41 @@
from datetime import datetime, timedelta
from pydantic import BaseModel
from pydantic.json import timedelta_isoformat


class CustomChildModel(BaseModel):
dt: datetime
diff: timedelta

class Config:
json_encoders = {
datetime: lambda v: v.timestamp(),
timedelta: timedelta_isoformat,
}


class ParentModel(BaseModel):
diff: timedelta
child: CustomChildModel

class Config:
json_encoders = {
timedelta: lambda v: v.total_seconds(),
CustomChildModel: lambda _: 'using parent encoder',
}


child = CustomChildModel(dt=datetime(2032, 6, 1), diff=timedelta(hours=100))
parent = ParentModel(diff=timedelta(hours=3), child=child)

# default encoder uses total_seconds() for diff
print(parent.json())

# nested encoder uses isoformat
print(parent.json(use_nested_encoders=True))

# turning off models_as_dict only uses the top-level formatter, however

print(parent.json(models_as_dict=False, use_nested_encoders=True))

print(parent.json(models_as_dict=False, use_nested_encoders=False))
14 changes: 14 additions & 0 deletions docs/usage/exporting_models.md
Expand Up @@ -118,6 +118,20 @@ In case of forward references, you can use a string with the class name instead
```
_(This script is complete, it should run "as is")_

### Nested serialisation of other models

By default, models that contain other models are serialised using the `json_encoders` functions of the
parent or container class.
However, you may want to nest classes in a modular fashion, including their `json_encoders`.
In this case, call `json(use_nested_encoders=True)`.
`use_nested_encoders` has no effect when `models_as_dict=False`, as the classes of the models
are expected to be defined in the top-level `json_encoders`.

```py
{!.tmp_examples/exporting_models_json_nested_encoders.py!}
```
_(This script is complete, it should run "as is")_

### Serialising subclasses

!!! note
Expand Down
11 changes: 11 additions & 0 deletions pydantic/main.py
Expand Up @@ -435,6 +435,7 @@ def dict(
exclude_unset: bool = False,
exclude_defaults: bool = False,
exclude_none: bool = False,
encode_as_json: bool = False,
) -> 'DictStrAny':
"""
Generate a dictionary representation of the model, optionally specifying which fields to include or exclude.
Expand All @@ -456,6 +457,7 @@ def dict(
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
encode_as_json=encode_as_json,
)
)

Expand All @@ -471,6 +473,7 @@ def json(
exclude_none: bool = False,
encoder: Optional[Callable[[Any], Any]] = None,
models_as_dict: bool = True,
use_nested_encoders: bool = False,
**dumps_kwargs: Any,
) -> str:
"""
Expand Down Expand Up @@ -498,6 +501,7 @@ def json(
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
encode_as_json=use_nested_encoders,
)
)
if self.__custom_root_type__:
Expand Down Expand Up @@ -716,6 +720,7 @@ def _get_value(
exclude_unset: bool,
exclude_defaults: bool,
exclude_none: bool,
encode_as_json: bool = False,
) -> Any:

if isinstance(v, BaseModel):
Expand All @@ -727,6 +732,7 @@ def _get_value(
include=include,
exclude=exclude,
exclude_none=exclude_none,
encode_as_json=encode_as_json,
)
if ROOT_KEY in v_dict:
return v_dict[ROOT_KEY]
Expand Down Expand Up @@ -776,6 +782,9 @@ def _get_value(
elif isinstance(v, Enum) and getattr(cls.Config, 'use_enum_values', False):
return v.value

elif encode_as_json:
return cls.__json_encoder__(v)

else:
return v

Expand Down Expand Up @@ -809,6 +818,7 @@ def _iter(
exclude_unset: bool = False,
exclude_defaults: bool = False,
exclude_none: bool = False,
encode_as_json: bool = False,
) -> 'TupleGenerator':

# Merge field set excludes with explicit exclude parameter with explicit overriding field set options.
Expand Down Expand Up @@ -854,6 +864,7 @@ def _iter(
exclude_unset=exclude_unset,
exclude_defaults=exclude_defaults,
exclude_none=exclude_none,
encode_as_json=encode_as_json,
)
yield dict_key, v

Expand Down
78 changes: 78 additions & 0 deletions tests/test_json.py
Expand Up @@ -372,3 +372,81 @@ class Model(BaseModel):
nested: Optional[BaseModel]

assert Model(value=None, nested=Model(value=None)).json(exclude_none=True) == '{"nested": {}}'


class WithCustomEncoders(BaseModel):
dt: datetime.datetime
diff: datetime.timedelta

class Config:
json_encoders = {
datetime.datetime: lambda v: v.timestamp(),
datetime.timedelta: timedelta_isoformat,
}


ides_of_march = datetime.datetime(44, 3, 15, tzinfo=datetime.timezone.utc)

child = WithCustomEncoders(
dt=datetime.datetime(2032, 6, 1, tzinfo=datetime.timezone.utc),
diff=datetime.timedelta(hours=100),
)


def test_inner_custom_encoding():
assert child.json() == r'{"dt": 1969660800.0, "diff": "P4DT4H0M0.000000S"}'


def test_encoding_in_parent_with_variable_encoders():
class ParentWithVariableEncoders(BaseModel):
dt: datetime.datetime
child: WithCustomEncoders

class Config:
json_encoders = {
datetime.datetime: lambda v: v.year,
datetime.timedelta: lambda v: v.total_seconds(),
}

parent = ParentWithVariableEncoders(child=child, dt=ides_of_march)

default = r'{"dt": 44, "child": {"dt": 2032, "diff": 360000.0}}'
assert parent.json() == default
# turning off models_as_dict defaults to top-level
assert parent.json(models_as_dict=False, use_nested_encoders=False) == default
assert parent.json(models_as_dict=False, use_nested_encoders=True) == default

custom = (
r'{"dt": 44, ' # parent.dt still uses the year to encode
# child uses child.json_encoders to encode
r'"child": {"dt": 1969660800.0, "diff": "P4DT4H0M0.000000S"}}'
)
assert parent.json(use_nested_encoders=True) == custom


def test_encoding_in_parent_with_class_encoders():
class ParentWithClassEncoders(BaseModel):
dt: datetime.datetime
child: WithCustomEncoders

class Config:
json_encoders = {
datetime.datetime: lambda v: v.timestamp(),
WithCustomEncoders: lambda v: {'dt': v.dt.year},
}

parent = ParentWithClassEncoders(child=child, dt=ides_of_march)

# when models_as_dict=True, the `WithCustomEncoders` encoder is ignored
default = r'{"dt": -60772291200.0, "child": {"dt": 1969660800.0, "diff": 360000.0}}'
assert parent.json() == default

custom_child = r'{"dt": -60772291200.0, "child": {"dt": 1969660800.0, "diff": "P4DT4H0M0.000000S"}}'
assert parent.json(use_nested_encoders=True) == custom_child

# when models_as_dict=False, the parent `WithCustomEncoders` is used
# regardless of whatever json_encoders are in WithCustomEncoders.Config

custom_parent = r'{"dt": -60772291200.0, "child": {"dt": 2032}}'
assert parent.json(models_as_dict=False, use_nested_encoders=False) == custom_parent
assert parent.json(models_as_dict=False, use_nested_encoders=True) == custom_parent

0 comments on commit b42fae0

Please sign in to comment.