diff --git a/Makefile b/Makefile index 03ac913a99..04f1d6e5ad 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ sources = pydantic tests docs/plugins .PHONY: install ## Install the package, dependencies, and pre-commit for local development install: .pdm .pre-commit + pdm info pdm install --group :all pre-commit install --install-hooks diff --git a/pydantic/_internal/_dataclasses.py b/pydantic/_internal/_dataclasses.py index b9f4cda45c..9b8707716f 100644 --- a/pydantic/_internal/_dataclasses.py +++ b/pydantic/_internal/_dataclasses.py @@ -20,7 +20,7 @@ from ._fields import collect_dataclass_fields from ._generate_schema import GenerateSchema from ._generics import get_standard_typevars_map -from ._mock_validator import set_dataclass_mock_validator +from ._mock_val_ser import set_dataclass_mock_validator from ._schema_generation_shared import CallbackGetCoreSchemaHandler if typing.TYPE_CHECKING: diff --git a/pydantic/_internal/_mock_val_ser.py b/pydantic/_internal/_mock_val_ser.py new file mode 100644 index 0000000000..ea03a68624 --- /dev/null +++ b/pydantic/_internal/_mock_val_ser.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable, Generic, TypeVar + +from pydantic_core import SchemaSerializer, SchemaValidator +from typing_extensions import Literal + +from ..errors import PydanticErrorCodes, PydanticUserError + +if TYPE_CHECKING: + from ..dataclasses import PydanticDataclass + from ..main import BaseModel + + +ValSer = TypeVar('ValSer', SchemaValidator, SchemaSerializer) + + +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. + """ + + __slots__ = '_error_message', '_code', '_val_or_ser', '_attempt_rebuild' + + def __init__( + self, + error_message: str, + *, + code: PydanticErrorCodes, + val_or_ser: Literal['validator', 'serializer'], + attempt_rebuild: Callable[[], ValSer | None] | None = None, + ) -> None: + self._error_message = error_message + self._val_or_ser = SchemaValidator if val_or_ser == 'validator' else SchemaSerializer + self._code: PydanticErrorCodes = code + self._attempt_rebuild = attempt_rebuild + + def __getattr__(self, item: str) -> None: + __tracebackhide__ = True + if self._attempt_rebuild: + val_ser = self._attempt_rebuild() + if val_ser is not None: + return getattr(val_ser, item) + + # raise an AttributeError if `item` doesn't exist + getattr(self._val_or_ser, item) + raise PydanticUserError(self._error_message, code=self._code) + + def rebuild(self) -> ValSer | 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 + + +def set_model_mocks(cls: type[BaseModel], cls_name: str, undefined_name: str = 'all referenced types') -> None: + """Set `__pydantic_validator__` and `__pydantic_serializer__` to `MockValSer`s on a model. + + Args: + cls: The model class to set the mocks on + cls_name: Name of the model class, used in error messages + undefined_name: Name of the undefined thing, used in error messages + """ + undefined_type_error_message = ( + f'`{cls_name}` is not fully defined; you should define {undefined_name},' + f' then call `{cls_name}.model_rebuild()`.' + ) + + def attempt_rebuild_validator() -> SchemaValidator | None: + if cls.model_rebuild(raise_errors=False, _parent_namespace_depth=5): + return cls.__pydantic_validator__ + else: + return None + + cls.__pydantic_validator__ = MockValSer( # type: ignore[assignment] + undefined_type_error_message, + code='class-not-fully-defined', + val_or_ser='validator', + attempt_rebuild=attempt_rebuild_validator, + ) + + def attempt_rebuild_serializer() -> SchemaSerializer | None: + if cls.model_rebuild(raise_errors=False, _parent_namespace_depth=5): + return cls.__pydantic_serializer__ + else: + return None + + cls.__pydantic_serializer__ = MockValSer( # type: ignore[assignment] + undefined_type_error_message, + code='class-not-fully-defined', + val_or_ser='serializer', + attempt_rebuild=attempt_rebuild_serializer, + ) + + +def set_dataclass_mock_validator(cls: type[PydanticDataclass], cls_name: str, undefined_name: str) -> None: + undefined_type_error_message = ( + f'`{cls_name}` is not fully defined; you should define {undefined_name},' + f' then call `pydantic.dataclasses.rebuild_dataclass({cls_name})`.' + ) + + def attempt_rebuild() -> SchemaValidator | None: + from ..dataclasses import rebuild_dataclass + + if rebuild_dataclass(cls, raise_errors=False, _parent_namespace_depth=5): + return cls.__pydantic_validator__ + else: + return None + + cls.__pydantic_validator__ = MockValSer( # type: ignore[assignment] + undefined_type_error_message, + code='class-not-fully-defined', + val_or_ser='validator', + attempt_rebuild=attempt_rebuild, + ) diff --git a/pydantic/_internal/_mock_validator.py b/pydantic/_internal/_mock_validator.py deleted file mode 100644 index b6b46734b0..0000000000 --- a/pydantic/_internal/_mock_validator.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Callable - -from pydantic_core import SchemaValidator - -from pydantic.errors import PydanticErrorCodes, PydanticUserError - -if TYPE_CHECKING: - from ..dataclasses import PydanticDataclass - from ..main import BaseModel - - -class MockValidator: - """Mocker for `pydantic_core.SchemaValidator` which just raises an error when one of its methods is accessed.""" - - __slots__ = '_error_message', '_code', '_attempt_rebuild' - - def __init__( - self, - error_message: str, - *, - code: PydanticErrorCodes, - attempt_rebuild: Callable[[], SchemaValidator | None] | None = None, - ) -> None: - self._error_message = error_message - self._code: PydanticErrorCodes = code - self._attempt_rebuild = attempt_rebuild - - def __getattr__(self, item: str) -> None: - __tracebackhide__ = True - if self._attempt_rebuild: - validator = self._attempt_rebuild() - if validator is not None: - return getattr(validator, item) - - # raise an AttributeError if `item` doesn't exist - getattr(SchemaValidator, item) - raise PydanticUserError(self._error_message, code=self._code) - - def rebuild(self) -> SchemaValidator | None: - if self._attempt_rebuild: - validator = self._attempt_rebuild() - if validator is not None: - return validator - else: - raise PydanticUserError(self._error_message, code=self._code) - return None - - -def set_basemodel_mock_validator( - cls: type[BaseModel], cls_name: str, undefined_name: str = 'all referenced types' -) -> None: - undefined_type_error_message = ( - f'`{cls_name}` is not fully defined; you should define {undefined_name},' - f' then call `{cls_name}.model_rebuild()`.' - ) - - def attempt_rebuild() -> SchemaValidator | None: - if cls.model_rebuild(raise_errors=False, _parent_namespace_depth=5): - return cls.__pydantic_validator__ - else: - return None - - cls.__pydantic_validator__ = MockValidator( # type: ignore[assignment] - undefined_type_error_message, code='class-not-fully-defined', attempt_rebuild=attempt_rebuild - ) - - -def set_dataclass_mock_validator(cls: type[PydanticDataclass], cls_name: str, undefined_name: str) -> None: - undefined_type_error_message = ( - f'`{cls_name}` is not fully defined; you should define {undefined_name},' - f' then call `pydantic.dataclasses.rebuild_dataclass({cls_name})`.' - ) - - def attempt_rebuild() -> SchemaValidator | None: - from ..dataclasses import rebuild_dataclass - - if rebuild_dataclass(cls, raise_errors=False, _parent_namespace_depth=5): - return cls.__pydantic_validator__ - else: - return None - - cls.__pydantic_validator__ = MockValidator( # type: ignore[assignment] - undefined_type_error_message, code='class-not-fully-defined', attempt_rebuild=attempt_rebuild - ) diff --git a/pydantic/_internal/_model_construction.py b/pydantic/_internal/_model_construction.py index 37655c4735..e70c648d4f 100644 --- a/pydantic/_internal/_model_construction.py +++ b/pydantic/_internal/_model_construction.py @@ -22,7 +22,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_validator import MockValidator, set_basemodel_mock_validator +from ._mock_val_ser import MockValSer, set_model_mocks from ._schema_generation_shared import CallbackGetCoreSchemaHandler from ._typing_extra import get_cls_types_namespace, is_classvar, parent_frame_namespace from ._utils import ClassAttribute, is_valid_identifier @@ -202,7 +202,7 @@ def __getattr__(self, item: str) -> Any: 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, MockValidator): + 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 @@ -461,7 +461,7 @@ def complete_model_class( ) if config_wrapper.defer_build: - set_basemodel_mock_validator(cls, cls_name) + set_model_mocks(cls, cls_name) return False try: @@ -469,7 +469,7 @@ def complete_model_class( except PydanticUndefinedAnnotation as e: if raise_errors: raise - set_basemodel_mock_validator(cls, cls_name, f'`{e.name}`') + set_model_mocks(cls, cls_name, f'`{e.name}`') return False core_config = config_wrapper.core_config(cls) @@ -477,7 +477,7 @@ def complete_model_class( schema = gen_schema.collect_definitions(schema) schema = apply_discriminators(flatten_schema_defs(schema)) if collect_invalid_schemas(schema): - set_basemodel_mock_validator(cls, cls_name) + set_model_mocks(cls, cls_name) return False # debug(schema) diff --git a/pydantic/json_schema.py b/pydantic/json_schema.py index 8e753e827f..a23668af54 100644 --- a/pydantic/json_schema.py +++ b/pydantic/json_schema.py @@ -41,7 +41,7 @@ from pydantic._internal import _annotated_handlers, _internal_dataclass -from ._internal import _core_metadata, _core_utils, _mock_validator, _schema_generation_shared, _typing_extra +from ._internal import _core_metadata, _core_utils, _mock_val_ser, _schema_generation_shared, _typing_extra from .config import JsonSchemaExtraCallable from .errors import PydanticInvalidForJsonSchema, PydanticUserError @@ -2119,7 +2119,7 @@ def model_json_schema( The generated JSON Schema. """ schema_generator_instance = schema_generator(by_alias=by_alias, ref_template=ref_template) - if isinstance(cls.__pydantic_validator__, _mock_validator.MockValidator): + if isinstance(cls.__pydantic_validator__, _mock_val_ser.MockValSer): cls.__pydantic_validator__.rebuild() assert '__pydantic_core_schema__' in cls.__dict__, 'this is a bug! please report it' return schema_generator_instance.generate(cls.__pydantic_core_schema__, mode=mode) diff --git a/pydantic/main.py b/pydantic/main.py index dd2050682b..1e830c62d5 100644 --- a/pydantic/main.py +++ b/pydantic/main.py @@ -18,7 +18,7 @@ _fields, _forward_ref, _generics, - _mock_validator, + _mock_val_ser, _model_construction, _repr, _typing_extra, @@ -134,8 +134,14 @@ class BaseModel(metaclass=_model_construction.ModelMetaclass): model_fields = {} __pydantic_decorators__ = _decorators.DecoratorInfos() # Prevent `BaseModel` from being instantiated directly: - __pydantic_validator__ = _mock_validator.MockValidator( + __pydantic_validator__ = _mock_val_ser.MockValSer( 'Pydantic models should inherit from BaseModel, BaseModel cannot be instantiated directly', + val_or_ser='validator', + code='base-model-instantiated', + ) + __pydantic_serializer__ = _mock_val_ser.MockValSer( + 'Pydantic models should inherit from BaseModel, BaseModel cannot be instantiated directly', + val_or_ser='serializer', code='base-model-instantiated', ) diff --git a/tests/test_config.py b/tests/test_config.py index 4e85bfb2e6..af9ea92c25 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,10 +4,10 @@ from contextlib import nullcontext as does_not_raise from decimal import Decimal from inspect import signature -from typing import Any, ContextManager, Iterable, NamedTuple, Type, Union, get_type_hints +from typing import Any, ContextManager, Iterable, NamedTuple, Optional, Type, Union, get_type_hints from dirty_equals import HasRepr, IsPartialDict -from pydantic_core import SchemaError, SchemaValidator +from pydantic_core import SchemaError, SchemaSerializer, SchemaValidator from pydantic import ( BaseConfig, @@ -22,10 +22,11 @@ validate_call, ) from pydantic._internal._config import ConfigWrapper, config_defaults -from pydantic._internal._mock_validator import MockValidator +from pydantic._internal._mock_val_ser import MockValSer from pydantic.config import ConfigDict from pydantic.dataclasses import dataclass as pydantic_dataclass from pydantic.errors import PydanticUserError +from pydantic.fields import FieldInfo from pydantic.type_adapter import TypeAdapter from pydantic.warnings import PydanticDeprecationWarning @@ -676,12 +677,14 @@ def test_config_model_defer_build(): class MyModel(BaseModel, defer_build=True): x: int - assert isinstance(MyModel.__pydantic_validator__, MockValidator) + assert isinstance(MyModel.__pydantic_validator__, MockValSer) + assert isinstance(MyModel.__pydantic_serializer__, MockValSer) m = MyModel(x=1) assert m.x == 1 assert isinstance(MyModel.__pydantic_validator__, SchemaValidator) + assert isinstance(MyModel.__pydantic_serializer__, SchemaSerializer) def test_config_model_defer_build_nested(): @@ -691,9 +694,58 @@ class MyNestedModel(BaseModel, defer_build=True): class MyModel(BaseModel): y: MyNestedModel - assert isinstance(MyNestedModel.__pydantic_validator__, MockValidator) + assert isinstance(MyNestedModel.__pydantic_validator__, MockValSer) + assert isinstance(MyNestedModel.__pydantic_serializer__, MockValSer) m = MyModel(y={'x': 1}) assert m.model_dump() == {'y': {'x': 1}} - assert isinstance(MyNestedModel.__pydantic_validator__, MockValidator) + assert isinstance(MyNestedModel.__pydantic_validator__, MockValSer) + assert isinstance(MyNestedModel.__pydantic_serializer__, MockValSer) + + +def test_config_model_defer_build_ser_first(): + class M1(BaseModel, defer_build=True): + a: str + + class M2(BaseModel, defer_build=True): + b: M1 + + m = M2.model_validate({'b': {'a': 'foo'}}) + assert m.b.model_dump() == {'a': 'foo'} + + +def test_defer_build_json_schema(): + class M(BaseModel, defer_build=True): + a: int + + assert M.model_json_schema() == { + 'title': 'M', + 'type': 'object', + 'properties': {'a': {'title': 'A', 'type': 'integer'}}, + 'required': ['a'], + } + + +def test_partial_creation_with_defer_build(): + class M(BaseModel): + a: int + b: int + + def create_partial(model, optionals): + override_fields = {} + model.model_rebuild() + for name, field in model.model_fields.items(): + if field.is_required() and name in optionals: + assert field.annotation is not None + override_fields[name] = (Optional[field.annotation], FieldInfo.merge_field_infos(field, default=None)) + + return create_model(f'Partial{model.__name__}', __base__=model, **override_fields) + + partial = create_partial(M, {'a'}) + + # Comment this away and the last assertion works + assert M.model_json_schema()['required'] == ['a', 'b'] + + # AssertionError: assert ['a', 'b'] == ['b'] + assert partial.model_json_schema()['required'] == ['b'] diff --git a/tests/test_dataclasses.py b/tests/test_dataclasses.py index 73c67c88b8..ce6ccc2757 100644 --- a/tests/test_dataclasses.py +++ b/tests/test_dataclasses.py @@ -32,7 +32,7 @@ field_validator, model_validator, ) -from pydantic._internal._mock_validator import MockValidator +from pydantic._internal._mock_val_ser import MockValSer from pydantic.dataclasses import rebuild_dataclass from pydantic.fields import Field, FieldInfo from pydantic.json_schema import model_json_schema @@ -1530,8 +1530,8 @@ class D2: D2 = module.D2 # Confirm D1 and D2 require rebuilding - assert isinstance(D1.__pydantic_validator__, MockValidator) - assert isinstance(D2.__pydantic_validator__, MockValidator) + assert isinstance(D1.__pydantic_validator__, MockValSer) + assert isinstance(D2.__pydantic_validator__, MockValSer) # Note: the rebuilds of D1 and D2 happen automatically, and works since it grabs the locals here as the namespace, # which contains D1 and D2 @@ -1597,7 +1597,7 @@ class D2: rebuild_dataclass(D1, _types_namespace={'D2': module.D2, 'D1': D1}) # Confirm D2 still requires a rebuild (it will happen automatically) - assert isinstance(module.D2.__pydantic_validator__, MockValidator) + assert isinstance(module.D2.__pydantic_validator__, MockValSer) instance = D1(d2=module.D2(d1=D1(d2=module.D2(d1=D1()))))