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

Fix TypeAdapter to respect defer_build #8939

Merged
merged 15 commits into from Apr 28, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion mkdocs.yml
Expand Up @@ -171,7 +171,7 @@ plugins:
options:
members_order: source
separate_signature: true
filters: ["!^_"]
filters: ["!^_(?!defer_build_mode)"]
docstring_options:
ignore_init_summary: true
merge_init_into_class: true
Expand Down
2 changes: 2 additions & 0 deletions pydantic/_internal/_config.py
Expand Up @@ -75,6 +75,7 @@ class ConfigWrapper:
protected_namespaces: tuple[str, ...]
hide_input_in_errors: bool
defer_build: bool
_defer_build_mode: tuple[Literal['model', 'type_adapter'], ...]
plugin_settings: dict[str, object] | None
schema_generator: type[GenerateSchema] | None
json_schema_serialization_defaults_required: bool
Expand Down Expand Up @@ -254,6 +255,7 @@ def push(self, config_wrapper: ConfigWrapper | ConfigDict | None):
hide_input_in_errors=False,
json_encoders=None,
defer_build=False,
_defer_build_mode=('model',),
plugin_settings=None,
schema_generator=None,
json_schema_serialization_defaults_required=False,
Expand Down
10 changes: 6 additions & 4 deletions pydantic/_internal/_generate_schema.py
Expand Up @@ -44,7 +44,7 @@
from ..json_schema import JsonSchemaValue
from ..version import version_short
from ..warnings import PydanticDeprecatedSince20
from . import _core_utils, _decorators, _discriminated_union, _known_annotated_metadata, _typing_extra
from . import _core_utils, _decorators, _discriminated_union, _known_annotated_metadata, _mock_val_ser, _typing_extra
sydney-runkle marked this conversation as resolved.
Show resolved Hide resolved
from ._config import ConfigWrapper, ConfigWrapperStack
from ._core_metadata import CoreMetadataHandler, build_metadata_dict
from ._core_utils import (
Expand Down Expand Up @@ -646,9 +646,11 @@ def _generate_schema_from_property(self, obj: Any, source: Any) -> core_schema.C
source, CallbackGetCoreSchemaHandler(self._generate_schema_inner, self, ref_mode=ref_mode)
)
# fmt: off
elif (existing_schema := getattr(obj, '__pydantic_core_schema__', None)) is not None and existing_schema.get(
'cls', None
) == obj:
elif (
(existing_schema := getattr(obj, '__pydantic_core_schema__', None)) is not None
and not isinstance(existing_schema, _mock_val_ser.MockCoreSchema)
and existing_schema.get('cls', None) == obj
):
sydney-runkle marked this conversation as resolved.
Show resolved Hide resolved
schema = existing_schema
# fmt: on
elif (validators := getattr(obj, '__get_validators__', None)) is not None:
Expand Down
81 changes: 79 additions & 2 deletions pydantic/_internal/_mock_val_ser.py
@@ -1,8 +1,8 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Callable, Generic, TypeVar
from typing import TYPE_CHECKING, Any, Callable, Generic, Iterator, Mapping, TypeVar

from pydantic_core import SchemaSerializer, SchemaValidator
from pydantic_core import CoreSchema, SchemaSerializer, SchemaValidator
from typing_extensions import Literal

from ..errors import PydanticErrorCodes, PydanticUserError
Expand All @@ -15,6 +15,59 @@
ValSer = TypeVar('ValSer', SchemaValidator, SchemaSerializer)


class MockCoreSchema(Mapping[str, Any]):
"""Mocker for `pydantic_core.CoreSchema` which optionally attempts to
rebuild the thing it's mocking when one of its methods is accessed and raises an error if that fails.
"""

__slots__ = '_error_message', '_code', '_attempt_rebuild', '_built_memo'

def __init__(
self,
error_message: str,
*,
code: PydanticErrorCodes,
attempt_rebuild: Callable[[], CoreSchema | None] | None = None,
) -> None:
self._error_message = error_message
self._code: PydanticErrorCodes = code
self._attempt_rebuild = attempt_rebuild
self._built_memo: CoreSchema | None = None
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I used _built_memo memoizer here to avoid cases where user could capture the internal mocker class and then use it. That would again and again go through the model rebuilder which has deeply the core schema etc memoized after its built. Should the existing MockValSer for consistency also have its memoizer so it wont accidentally go again and again through the wrapped class deep memoizer?


def __contains__(self, key: Any) -> bool:
return self._get_built().__contains__(key)

def __getitem__(self, key: str) -> Any:
return self._get_built().__getitem__(key)

def __len__(self) -> int:
return self._get_built().__len__()

def __iter__(self) -> Iterator[str]:
return self._get_built().__iter__()

def _get_built(self) -> CoreSchema:
if self._built_memo is not None:
return self._built_memo

if self._attempt_rebuild:
schema = self._attempt_rebuild()
if schema is not None:
self._built_memo = schema
return schema
raise PydanticUserError(self._error_message, code=self._code)
Copy link
Member

Choose a reason for hiding this comment

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

Are these necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What do you mean by necessary? For abstract Mapping the __getitem__ / __len__ / __iter__ are required

Ill remove the __contains__ as it doesnt need overriding.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done, removed the unneeded __contains__ override

Copy link
Member

Choose a reason for hiding this comment

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

I guess I mean they aren't implemented for MockValSer, right? So why have them here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

CoreSchema is a dict but eg SchemaValidator is an ordinary class


def rebuild(self) -> CoreSchema | None:
self._built_memo = None
if self._attempt_rebuild:
val_ser = self._attempt_rebuild()
if val_ser is not None:
return val_ser
else:
raise PydanticUserError(self._error_message, code=self._code)
return None


class MockValSer(Generic[ValSer]):
"""Mocker for `pydantic_core.SchemaValidator` or `pydantic_core.SchemaSerializer` which optionally attempts to
rebuild the thing it's mocking when one of its methods is accessed and raises an error if that fails.
Expand Down Expand Up @@ -69,6 +122,18 @@ def set_model_mocks(cls: type[BaseModel], cls_name: str, undefined_name: str = '
f' then call `{cls_name}.model_rebuild()`.'
)

def attempt_rebuild_core_schema() -> CoreSchema | None:
if cls.model_rebuild(raise_errors=False, _parent_namespace_depth=5) is not False:
return cls.__pydantic_core_schema__
else:
return None

cls.__pydantic_core_schema__ = MockCoreSchema( # type: ignore[assignment]
undefined_type_error_message,
code='class-not-fully-defined',
attempt_rebuild=attempt_rebuild_core_schema,
)

sydney-runkle marked this conversation as resolved.
Show resolved Hide resolved
def attempt_rebuild_validator() -> SchemaValidator | None:
if cls.model_rebuild(raise_errors=False, _parent_namespace_depth=5) is not False:
return cls.__pydantic_validator__
Expand Down Expand Up @@ -113,6 +178,18 @@ def set_dataclass_mocks(
f' then call `pydantic.dataclasses.rebuild_dataclass({cls_name})`.'
)

def attempt_rebuild_core_schema() -> CoreSchema | None:
sydney-runkle marked this conversation as resolved.
Show resolved Hide resolved
if rebuild_dataclass(cls, raise_errors=False, _parent_namespace_depth=5) is not False:
return cls.__pydantic_core_schema__
else:
return None

cls.__pydantic_core_schema__ = MockCoreSchema( # type: ignore[assignment]
undefined_type_error_message,
code='class-not-fully-defined',
attempt_rebuild=attempt_rebuild_core_schema,
)

def attempt_rebuild_validator() -> SchemaValidator | None:
if rebuild_dataclass(cls, raise_errors=False, _parent_namespace_depth=5) is not False:
return cls.__pydantic_validator__
Expand Down
12 changes: 2 additions & 10 deletions pydantic/_internal/_model_construction.py
Expand Up @@ -23,7 +23,7 @@
from ._fields import collect_model_fields, is_valid_field_name, is_valid_privateattr_name
from ._generate_schema import GenerateSchema
from ._generics import PydanticGenericMetadata, get_model_typevars_map
from ._mock_val_ser import MockValSer, set_model_mocks
from ._mock_val_ser import set_model_mocks
from ._schema_generation_shared import CallbackGetCoreSchemaHandler
from ._signature import generate_pydantic_signature
from ._typing_extra import get_cls_types_namespace, is_annotated, is_classvar, parent_frame_namespace
Expand Down Expand Up @@ -231,14 +231,6 @@ def __getattr__(self, item: str) -> Any:
private_attributes = self.__dict__.get('__private_attributes__')
if private_attributes and item in private_attributes:
return private_attributes[item]
if item == '__pydantic_core_schema__':
# This means the class didn't get a schema generated for it, likely because there was an undefined reference
maybe_mock_validator = getattr(self, '__pydantic_validator__', None)
if isinstance(maybe_mock_validator, MockValSer):
rebuilt_validator = maybe_mock_validator.rebuild()
if rebuilt_validator is not None:
# In this case, a validator was built, and so `__pydantic_core_schema__` should now be set
return getattr(self, '__pydantic_core_schema__')
sydney-runkle marked this conversation as resolved.
Show resolved Hide resolved
raise AttributeError(item)

@classmethod
Expand Down Expand Up @@ -531,7 +523,7 @@ def complete_model_class(
ref_mode='unpack',
)

if config_wrapper.defer_build:
if config_wrapper.defer_build and 'model' in config_wrapper._defer_build_mode:
set_model_mocks(cls, cls_name)
return False

Expand Down
28 changes: 26 additions & 2 deletions pydantic/config.py
Expand Up @@ -709,11 +709,35 @@ class Model(BaseModel):

defer_build: bool
"""
Whether to defer model validator and serializer construction until the first model validation.
Whether to defer model validator and serializer construction until the first model validation. Defaults to False.

This can be useful to avoid the overhead of building models which are only
used nested within other models, or when you want to manually define type namespace via
[`Model.model_rebuild(_types_namespace=...)`][pydantic.BaseModel.model_rebuild]. Defaults to False.
[`Model.model_rebuild(_types_namespace=...)`][pydantic.BaseModel.model_rebuild].

See also [`_defer_build_mode`][pydantic.config.ConfigDict._defer_build_mode].

!!! note
`defer_build` does not work by default with FastAPI Pydantic models. By default, the validator and serializer
for said models is constructed immediately for FastAPI routes. You also need to define
[`_defer_build_mode=('model', 'type_adapter')`][pydantic.config.ConfigDict._defer_build_mode] with FastAPI
models in order for `defer_build=True` to take effect. This additional (experimental) parameter is required for
the deferred building due to FastAPI relying on `TypeAdapter`s.
"""

_defer_build_mode: tuple[Literal['model', 'type_adapter'], ...]
MarkusSintonen marked this conversation as resolved.
Show resolved Hide resolved
"""
Controls when [`defer_build`][pydantic.config.ConfigDict.defer_build] is applicable. Defaults to `('model',)`.

Due to backwards compatibility reasons [`TypeAdapter`][pydantic.type_adapter.TypeAdapter] does not by default
respect `defer_build`. Meaning when `defer_build` is `True` and `_defer_build_mode` is the default `('model',)`
then `TypeAdapter` immediately constructs its validator and serializer instead of postponing said construction until
the first model validation. Set this to `('model', 'type_adapter')` to make `TypeAdapter` respect the `defer_build`
so it postpones validator and serializer construction until the first validation or serialization.

!!! note
The `_defer_build_mode` parameter is named with an underscore to suggest this is an experimental feature. It may
be removed or changed in the future in a minor release.
"""

plugin_settings: dict[str, object] | None
Expand Down
12 changes: 7 additions & 5 deletions pydantic/json_schema.py
Expand Up @@ -2226,12 +2226,14 @@ def model_json_schema(
from .main import BaseModel

schema_generator_instance = schema_generator(by_alias=by_alias, ref_template=ref_template)
if isinstance(cls.__pydantic_validator__, _mock_val_ser.MockValSer):
cls.__pydantic_validator__.rebuild()

if isinstance(cls.__pydantic_core_schema__, _mock_val_ser.MockCoreSchema):
cls.__pydantic_core_schema__.rebuild()

if cls is BaseModel:
raise AttributeError('model_json_schema() must be called on a subclass of BaseModel, not BaseModel itself.')
assert '__pydantic_core_schema__' in cls.__dict__, 'this is a bug! please report it'

assert not isinstance(cls.__pydantic_core_schema__, _mock_val_ser.MockCoreSchema), 'this is a bug! please report it'
return schema_generator_instance.generate(cls.__pydantic_core_schema__, mode=mode)


Expand Down Expand Up @@ -2263,8 +2265,8 @@ def models_json_schema(
element, along with the optional title and description keys.
"""
for cls, _ in models:
if isinstance(cls.__pydantic_validator__, _mock_val_ser.MockValSer):
cls.__pydantic_validator__.rebuild()
if isinstance(cls.__pydantic_core_schema__, _mock_val_ser.MockCoreSchema):
cls.__pydantic_core_schema__.rebuild()

instance = schema_generator(by_alias=by_alias, ref_template=ref_template)
inputs = [(m, mode, m.__pydantic_core_schema__) for m, mode in models]
Expand Down
7 changes: 6 additions & 1 deletion pydantic/main.py
Expand Up @@ -145,6 +145,10 @@ class BaseModel(metaclass=_model_construction.ModelMetaclass):
__pydantic_decorators__ = _decorators.DecoratorInfos()
__pydantic_parent_namespace__ = None
# Prevent `BaseModel` from being instantiated directly:
__pydantic_core_schema__ = _mock_val_ser.MockCoreSchema(
'Pydantic models should inherit from BaseModel, BaseModel cannot be instantiated directly',
code='base-model-instantiated',
)
__pydantic_validator__ = _mock_val_ser.MockValSer(
'Pydantic models should inherit from BaseModel, BaseModel cannot be instantiated directly',
val_or_ser='validator',
Expand Down Expand Up @@ -594,7 +598,8 @@ def __get_pydantic_core_schema__(cls, source: type[BaseModel], handler: GetCoreS
"""
# Only use the cached value from this _exact_ class; we don't want one from a parent class
# This is why we check `cls.__dict__` and don't use `cls.__pydantic_core_schema__` or similar.
if '__pydantic_core_schema__' in cls.__dict__:
schema = cls.__dict__.get('__pydantic_core_schema__')
if schema is not None and not isinstance(schema, _mock_val_ser.MockCoreSchema):
# Due to the way generic classes are built, it's possible that an invalid schema may be temporarily
# set on generic classes. I think we could resolve this to ensure that we get proper schema caching
# for generics, but for simplicity for now, we just always rebuild if the class has a generic origin.
Expand Down