Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions pydantic/_internal/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from pydantic_core import core_schema
from typing_extensions import Literal, Self

from ..config import ConfigDict, ExtraValues, JsonSchemaExtraCallable
from ..config import ConfigDict, ExtraValues, JsonEncoder, JsonSchemaExtraCallable
from ..errors import PydanticUserError
from ..warnings import PydanticDeprecatedSince20

Expand Down Expand Up @@ -47,6 +47,7 @@ class ConfigWrapper:
ignored_types: tuple[type, ...]
allow_inf_nan: bool
json_schema_extra: dict[str, object] | JsonSchemaExtraCallable | None
json_encoders: dict[type[object], JsonEncoder] | None

# new in V2
strict: bool
Expand Down Expand Up @@ -191,6 +192,7 @@ def __repr__(self):
validate_return=False,
protected_namespaces=('model_',),
hide_input_in_errors=False,
json_encoders=None,
)


Expand Down Expand Up @@ -227,7 +229,6 @@ def prepare_config(config: ConfigDict | dict[str, Any] | type[Any] | None) -> Co
'underscore_attrs_are_private',
'json_loads',
'json_dumps',
'json_encoders',
'copy_on_model_validation',
'post_init_call',
}
Expand Down
46 changes: 44 additions & 2 deletions pydantic/_internal/_generate_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import re
import sys
import typing
import warnings
from contextlib import contextmanager
from copy import copy
from enum import Enum
Expand All @@ -24,6 +25,7 @@
Iterable,
Iterator,
Mapping,
Type,
TypeVar,
Union,
cast,
Expand All @@ -34,10 +36,12 @@
from pydantic_core import CoreSchema, PydanticUndefined, core_schema
from typing_extensions import Annotated, Final, Literal, TypeAliasType, TypedDict, get_args, get_origin, is_typeddict

from ..config import ConfigDict
from ..config import ConfigDict, JsonEncoder
from ..errors import PydanticSchemaGenerationError, PydanticUndefinedAnnotation, PydanticUserError
from ..fields import AliasChoices, AliasPath, FieldInfo
from ..json_schema import JsonSchemaValue
from ..version import VERSION
from ..warnings import PydanticDeprecatedSince20
from . import _decorators, _discriminated_union, _known_annotated_metadata, _typing_extra
from ._annotated_handlers import GetCoreSchemaHandler, GetJsonSchemaHandler
from ._config import ConfigWrapper
Expand Down Expand Up @@ -210,6 +214,42 @@ def modify_model_json_schema(
return json_schema


JsonEncoders = Dict[Type[Any], JsonEncoder]


def _add_custom_serialization_from_json_encoders(
json_encoders: JsonEncoders | None, tp: Any, schema: CoreSchema
) -> CoreSchema:
"""Iterate over the json_encoders and add the first matching encoder to the schema.

Args:
json_encoders: A dictionary of types and their encoder functions.
tp: The type to check for a matching encoder.
schema: The schema to add the encoder to.
"""
if not json_encoders:
return schema
if 'serialization' in schema:
return schema
# Check the class type and its superclasses for a matching encoder
# Decimal.__class__.__mro__ (and probably other cases) doesn't include Decimal itself
for base in (tp, *tp.__class__.__mro__[:-1]):
encoder = json_encoders.get(base)
if encoder is None:
continue

warnings.warn(
f'`json_encoders` is deprecated. See https://docs.pydantic.dev/{VERSION}/usage/serialization/#custom-serializers for alternatives',
PydanticDeprecatedSince20,
Comment on lines +242 to +243
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is incorrect, it should only be the major and minor versions.

Maybe the solution is to have a proper warnings page like we have for user and validation errors. I can work on this today.

Fixing this should not block release.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

only for specific versions see #6527.

I'll provide a general fix.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see. Okay then we can just strip the patch version.

)

# TODO: in theory we should check that the schema accepts a serialization key
schema['serialization'] = core_schema.plain_serializer_function_ser_schema(encoder, when_used='json') # type: ignore
return schema

return schema


class GenerateSchema:
"""Generate core schema for a Pydantic model, dataclass and types like `str`, `datatime`, ... ."""

Expand Down Expand Up @@ -317,6 +357,8 @@ def _generate_schema_for_type(
if metadata_schema:
self._add_js_function(metadata_schema, metadata_js_function)

schema = _add_custom_serialization_from_json_encoders(self.config_wrapper.json_encoders, obj, schema)

return schema

def _model_schema(self, cls: type[BaseModel]) -> core_schema.CoreSchema:
Expand Down Expand Up @@ -1413,7 +1455,7 @@ def inner_handler(obj: Any) -> CoreSchema:
if pydantic_js_annotation_functions:
metadata = CoreMetadataHandler(schema).metadata
metadata.setdefault('pydantic_js_annotation_functions', []).extend(pydantic_js_annotation_functions)
return schema
return _add_custom_serialization_from_json_encoders(self.config_wrapper.json_encoders, source_type, schema)

def apply_single_annotation( # noqa: C901
self, schema: core_schema.CoreSchema, metadata: Any
Expand Down
9 changes: 9 additions & 0 deletions pydantic/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
__all__ = 'BaseConfig', 'ConfigDict', 'Extra'


JsonEncoder = Callable[[Any], Any]

JsonSchemaExtraCallable: TypeAlias = Union[
Callable[[Dict[str, Any]], None],
Callable[[Dict[str, Any], Type[Any]], None],
Expand Down Expand Up @@ -125,6 +127,12 @@ class without an annotation and has a type that is not in this tuple (or otherwi
hide_input_in_errors: Whether to hide inputs when printing errors. Defaults to `False`.

See [Hide Input in Errors](../usage/model_config.md#hide-input-in-errors).
json_encoders: A `dict` of custom JSON encoders for specific types. Defaults to `None`.

!!! note
This config option is a carryover from v1.
We originally planned to remove it in v2 but didn't have a 1:1 replacement so we are keeping it for now.
It is still deprecated and will likely be removed in the future.
"""

title: str | None
Expand All @@ -147,6 +155,7 @@ class without an annotation and has a type that is not in this tuple (or otherwi
ignored_types: tuple[type, ...]
allow_inf_nan: bool
json_schema_extra: dict[str, object] | JsonSchemaExtraCallable | None
json_encoders: dict[type[object], JsonEncoder] | None

# new in V2
strict: bool
Expand Down
35 changes: 33 additions & 2 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import re
import sys
from contextlib import nullcontext as does_not_raise
Expand All @@ -23,6 +24,8 @@
from pydantic.config import ConfigDict
from pydantic.dataclasses import dataclass as pydantic_dataclass
from pydantic.errors import PydanticUserError
from pydantic.type_adapter import TypeAdapter
from pydantic.warnings import PydanticDeprecationWarning

if sys.version_info < (3, 9):
from typing_extensions import Annotated
Expand Down Expand Up @@ -519,8 +522,10 @@ class Child(Mixin, Parent):
@pytest.mark.skipif(sys.version_info < (3, 10), reason='different on older versions')
def test_config_wrapper_match():
config_dict_annotations = [(k, str(v)) for k, v in get_type_hints(ConfigDict).items()]
config_dict_annotations.sort()
# remove config
config_wrapper_annotations = [(k, str(v)) for k, v in get_type_hints(ConfigWrapper).items() if k != 'config_dict']
config_wrapper_annotations.sort()

assert (
config_dict_annotations == config_wrapper_annotations
Expand All @@ -529,8 +534,8 @@ def test_config_wrapper_match():

@pytest.mark.skipif(sys.version_info < (3, 10), reason='different on older versions')
def test_config_defaults_match():
config_dict_keys = list(get_type_hints(ConfigDict).keys())
config_defaults_keys = list(config_defaults.keys())
config_dict_keys = sorted(list(get_type_hints(ConfigDict).keys()))
config_defaults_keys = sorted(list(config_defaults.keys()))

assert config_dict_keys == config_defaults_keys, 'ConfigDict and config_defaults must have the same keys'

Expand Down Expand Up @@ -633,3 +638,29 @@ class Child(Parent):
model_config: ConfigDict = {'str_to_lower': True}

assert Child.model_config == {'extra': 'allow', 'str_to_lower': True}


def test_json_encoders_model() -> None:
with pytest.warns(PydanticDeprecationWarning):

class Model(BaseModel):
model_config = ConfigDict(json_encoders={Decimal: lambda x: str(x * 2), int: lambda x: str(x * 3)})
value: Decimal
x: int

assert json.loads(Model(value=Decimal('1.1'), x=1).model_dump_json()) == {'value': '2.2', 'x': '3'}


@pytest.mark.filterwarnings('ignore::pydantic.warnings.PydanticDeprecationWarning')
def test_json_encoders_type_adapter() -> None:
config = ConfigDict(json_encoders={Decimal: lambda x: str(x * 2), int: lambda x: str(x * 3)})

ta = TypeAdapter(int, config=config)
assert json.loads(ta.dump_json(1)) == '3'

ta = TypeAdapter(Decimal, config=config)
assert json.loads(ta.dump_json(Decimal('1.1'))) == '2.2'

ta = TypeAdapter(Union[Decimal, int], config=config)
assert json.loads(ta.dump_json(Decimal('1.1'))) == '2.2'
assert json.loads(ta.dump_json(1)) == '2'
103 changes: 101 additions & 2 deletions tests/test_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@

import pytest
from pydantic_core import CoreSchema, SchemaSerializer, core_schema

from pydantic import BaseModel, ConfigDict, GetCoreSchemaHandler, GetJsonSchemaHandler, NameEmail
from typing_extensions import Annotated

from pydantic import (
AfterValidator,
BaseModel,
ConfigDict,
GetCoreSchemaHandler,
GetJsonSchemaHandler,
NameEmail,
PlainSerializer,
)
from pydantic._internal._config import ConfigWrapper
from pydantic._internal._generate_schema import GenerateSchema
from pydantic.color import Color
Expand Down Expand Up @@ -383,3 +392,93 @@ def __get_pydantic_json_schema__(
},
'allOf': [{'$ref': '#/$defs/Model'}],
}


def test_custom_json_encoder_config():
class Model(BaseModel):
x: timedelta
y: Decimal
z: date

model_config = ConfigDict(
json_encoders={timedelta: lambda v: f'{v.total_seconds():0.3f}s', Decimal: lambda v: 'a decimal'}
)

assert json.loads(Model(x=123, y=5, z='2032-06-01').model_dump_json()) == {
'x': '123.000s',
'y': 'a decimal',
'z': '2032-06-01',
}


def test_custom_iso_timedelta():
class Model(BaseModel):
x: timedelta
model_config = ConfigDict(json_encoders={timedelta: lambda _: 'P0DT0H2M3.000000S'})

m = Model(x=321)
assert json.loads(m.model_dump_json()) == {'x': 'P0DT0H2M3.000000S'}


def test_json_encoders_config_simple_inheritance():
"""json_encoders is not "inheritable", this is different than v1 but much simpler"""

class Parent(BaseModel):
dt: datetime = datetime.now()
timedt: timedelta = timedelta(hours=100)

model_config = ConfigDict(json_encoders={timedelta: lambda _: 'parent_encoder'})

class Child(Parent):
model_config = ConfigDict(json_encoders={datetime: lambda _: 'child_encoder'})

# insert_assert(Child().model_dump())
assert json.loads(Child().model_dump_json()) == {'dt': 'child_encoder', 'timedt': 'P4DT14400S'}


def test_custom_iso_timedelta_annotated():
class Model(BaseModel):
# the json_encoders config applies to the type but the annotation overrides it
y: timedelta
x: Annotated[timedelta, AfterValidator(lambda x: x), PlainSerializer(lambda _: 'P0DT0H1M2.000000S')]
model_config = ConfigDict(json_encoders={timedelta: lambda _: 'P0DT0H2M3.000000S'})

m = Model(x=321, y=456)
assert json.loads(m.model_dump_json()) == {'x': 'P0DT0H1M2.000000S', 'y': 'P0DT0H2M3.000000S'}


def test_json_encoders_on_model() -> None:
"""Make sure that applying json_encoders to a BaseModel
does not edit it's schema in place.
"""

class Model(BaseModel):
x: int

class Outer1(BaseModel):
m: Model
model_config = ConfigDict(json_encoders={Model: lambda x: 'encoded!'})

class Outer2(BaseModel):
m: Model

class Outermost(BaseModel):
inner: Union[Outer1, Outer2]

m = Outermost(inner=Outer1(m=Model(x=1)))
# insert_assert(m.model_dump())
assert json.loads(m.model_dump_json()) == {'inner': {'m': 'encoded!'}}

m = Outermost(inner=Outer2(m=Model(x=1)))
# insert_assert(m.model_dump())
assert json.loads(m.model_dump_json()) == {'inner': {'m': {'x': 1}}}


def test_json_encoders_not_used_for_python_dumps() -> None:
class Model(BaseModel):
x: int
model_config = ConfigDict(json_encoders={int: lambda x: 'encoded!'})

m = Model(x=1)
assert m.model_dump() == {'x': 1}
assert m.model_dump_json() == '{"x":"encoded!"}'