Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make fields with defaults not required in the serialization schema by default #7275

Merged
merged 13 commits into from Sep 18, 2023
51 changes: 49 additions & 2 deletions pydantic/_internal/_config.py
@@ -1,10 +1,21 @@
from __future__ import annotations as _annotations

import warnings
from typing import TYPE_CHECKING, Any, Callable, cast
from contextlib import contextmanager, nullcontext
from typing import (
TYPE_CHECKING,
Any,
Callable,
ContextManager,
Iterator,
cast,
)

from pydantic_core import core_schema
from typing_extensions import Literal, Self
from typing_extensions import (
Literal,
Self,
)

from ..config import ConfigDict, ExtraValues, JsonEncoder, JsonSchemaExtraCallable
from ..errors import PydanticUserError
Expand Down Expand Up @@ -65,6 +76,10 @@ class ConfigWrapper:
hide_input_in_errors: bool
defer_build: bool
schema_generator: type[GenerateSchema] | None
json_schema_serialization_defaults_required: bool
json_schema_mode_override: Literal['validation', 'serialization', None]
json_schema_validation_suffix: str
json_schema_serialization_suffix: str

def __init__(self, config: ConfigDict | dict[str, Any] | type[Any] | None, *, check: bool = True):
if check:
Expand Down Expand Up @@ -169,6 +184,34 @@ def __repr__(self):
return f'ConfigWrapper({c})'


class ConfigWrapperStack:
"""A stack of `ConfigWrapper` instances."""

def __init__(self, config_wrapper: ConfigWrapper):
self._config_wrapper_stack: list[ConfigWrapper] = [config_wrapper]

@property
def tail(self) -> ConfigWrapper:
return self._config_wrapper_stack[-1]

def push(self, config_wrapper: ConfigWrapper | ConfigDict | None) -> ContextManager[None]:
if config_wrapper is None:
return nullcontext()

if not isinstance(config_wrapper, ConfigWrapper):
config_wrapper = ConfigWrapper(config_wrapper, check=False)

@contextmanager
def _context_manager() -> Iterator[None]:
self._config_wrapper_stack.append(config_wrapper)
try:
yield
finally:
self._config_wrapper_stack.pop()

return _context_manager()


config_defaults = ConfigDict(
title=None,
str_to_lower=False,
Expand Down Expand Up @@ -200,6 +243,10 @@ def __repr__(self):
json_encoders=None,
defer_build=False,
schema_generator=None,
json_schema_serialization_defaults_required=False,
json_schema_mode_override=None,
json_schema_validation_suffix='-Input',
json_schema_serialization_suffix='-Output',
)


Expand Down
4 changes: 4 additions & 0 deletions pydantic/_internal/_core_metadata.py
Expand Up @@ -30,6 +30,8 @@ class CoreMetadata(typing_extensions.TypedDict, total=False):
# prefer positional over keyword arguments for an 'arguments' schema.
pydantic_js_prefer_positional_arguments: bool | None

pydantic_typed_dict_cls: type[Any] | None # TODO: Consider moving this into the pydantic-core TypedDictSchema


class CoreMetadataHandler:
"""Because the metadata field in pydantic_core is of type `Any`, we can't assume much about its contents.
Expand Down Expand Up @@ -67,6 +69,7 @@ def build_metadata_dict(
js_functions: list[GetJsonSchemaFunction] | None = None,
js_annotation_functions: list[GetJsonSchemaFunction] | None = None,
js_prefer_positional_arguments: bool | None = None,
typed_dict_cls: type[Any] | None = None,
initial_metadata: Any | None = None,
) -> Any:
"""Builds a dict to use as the metadata field of a CoreSchema object in a manner that is consistent
Expand All @@ -79,6 +82,7 @@ def build_metadata_dict(
pydantic_js_functions=js_functions or [],
pydantic_js_annotation_functions=js_annotation_functions or [],
pydantic_js_prefer_positional_arguments=js_prefer_positional_arguments,
pydantic_typed_dict_cls=typed_dict_cls,
)
metadata = {k: v for k, v in metadata.items() if v is not None}

Expand Down
37 changes: 5 additions & 32 deletions pydantic/_internal/_generate_schema.py
Expand Up @@ -8,7 +8,7 @@
import sys
import typing
import warnings
from contextlib import contextmanager, nullcontext
from contextlib import contextmanager
from copy import copy
from enum import Enum
from functools import partial
Expand All @@ -20,7 +20,6 @@
TYPE_CHECKING,
Any,
Callable,
ContextManager,
Dict,
ForwardRef,
Iterable,
Expand All @@ -45,7 +44,7 @@
from ..warnings import PydanticDeprecatedSince20
from . import _decorators, _discriminated_union, _known_annotated_metadata, _typing_extra
from ._annotated_handlers import GetCoreSchemaHandler, GetJsonSchemaHandler
from ._config import ConfigWrapper
from ._config import ConfigWrapper, ConfigWrapperStack
from ._core_metadata import (
CoreMetadataHandler,
build_metadata_dict,
Expand Down Expand Up @@ -260,34 +259,6 @@ def _add_custom_serialization_from_json_encoders(
return schema


class ConfigWrapperStack:
"""A stack of `ConfigWrapper` instances."""

def __init__(self, config_wrapper: ConfigWrapper):
self._config_wrapper_stack: list[ConfigWrapper] = [config_wrapper]

@property
def tail(self) -> ConfigWrapper:
return self._config_wrapper_stack[-1]

def push(self, config_wrapper: ConfigWrapper | ConfigDict | None) -> ContextManager[None]:
if config_wrapper is None:
return nullcontext()

if not isinstance(config_wrapper, ConfigWrapper):
config_wrapper = ConfigWrapper(config_wrapper, check=False)

@contextmanager
def _context_manager() -> Iterator[None]:
self._config_wrapper_stack.append(config_wrapper)
try:
yield
finally:
self._config_wrapper_stack.pop()

return _context_manager()


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

Expand Down Expand Up @@ -1098,7 +1069,9 @@ def _typed_dict_schema(self, typed_dict_cls: Any, origin: Any) -> core_schema.Co
field_name, field_info, decorators, required=required
)

metadata = build_metadata_dict(js_functions=[partial(modify_model_json_schema, cls=typed_dict_cls)])
metadata = build_metadata_dict(
js_functions=[partial(modify_model_json_schema, cls=typed_dict_cls)], typed_dict_cls=typed_dict_cls
)

td_schema = core_schema.typed_dict_schema(
fields,
Expand Down
42 changes: 42 additions & 0 deletions pydantic/config.py
Expand Up @@ -207,5 +207,47 @@ class without an annotation and has a type that is not in this tuple (or otherwi
Defaults to `None`.
"""

json_schema_serialization_defaults_required: bool
"""
Whether fields with default values should be marked as required in the serialization schema.

This ensures that the serialization schema will reflect the fact a field with a default will always be present
when serializing the model, even though it is not required for validation.

However, there are scenarios where this may be undesirable — in particular, if you want to share the schema
between validation and serialization, and don't mind fields with defaults being marked as not required during
serialization. See [#7209](https://github.com/pydantic/pydantic/issues/7209) for more details.

Defaults to `False`.
"""

json_schema_mode_override: Literal['validation', 'serialization', None]
"""
If not `None`, the specified mode will be used to generate the JSON schema regardless of what `mode` was passed to
the function call.

This provides a way to force the JSON schema generation to reflect a specific mode, e.g., to always use the
validation schema, even if a framework (like FastAPI) might be indicating to use the serialization schema in some
places.

Defaults to `None`.
"""

json_schema_validation_suffix: str
"""
When the validation and serialization schemas for a single model are different, and both schemas must be referenced
in a single generated JSON schema, this suffix will be added to the schema for `mode='validation'`.

Defaults to `'-Input'`.
"""

json_schema_serialization_suffix: str
"""
When the validation and serialization schemas for a single model are different, and both schemas must be referenced
in a single generated JSON schema, this suffix will be added to the schema for `mode='serialization'`.

Defaults to `'-Output'`.
"""


__getattr__ = getattr_migration(__name__)