From 1280bd3c80ae81a066663cdd265121cfef3193af Mon Sep 17 00:00:00 2001 From: Christian Hattemer Date: Fri, 25 Oct 2024 16:51:08 +0200 Subject: [PATCH 1/2] Allow to keep the types as they were and just add a default value It's still possible to omit all unchanged fields in PATCH requests, but for fields that are given Pydantic will reject data that specifies an explicit null for fields that aren't optional in the full model. This is useful when using the package with SQLModel. Without this feature the client could try to assign a NULL value to a column with a NOT NULL constraint, which would raise an IntegrityError. --- pydantic_partial/partial.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/pydantic_partial/partial.py b/pydantic_partial/partial.py index 7e6868c..69cf1b4 100644 --- a/pydantic_partial/partial.py +++ b/pydantic_partial/partial.py @@ -41,6 +41,7 @@ def create_partial_model( base_cls: type[SelfT], *fields: str, recursive: bool = False, + optional: bool = True, ) -> type[SelfT]: # Convert one type to being partial - if possible def _partial_annotation_arg(field_name_: str, field_annotation: type) -> type: @@ -104,8 +105,15 @@ def _partial_annotation_arg(field_name_: str, field_annotation: type) -> type: # Construct new field definition if field_name in fields_: if model_compat.is_model_field_info_required(field_info): + # Allow to keep the types as they were and just add a + # default value + if optional: + annotation = Optional[field_annotation] + else: + annotation = field_annotation + optional_fields[field_name] = ( - Optional[field_annotation], + annotation, model_compat.copy_model_field_info( field_info, default=None, # Set default to None From 797ae6d50f6ea3b7480301c429aa49d8216347ad Mon Sep 17 00:00:00 2001 From: Christian Hattemer Date: Thu, 5 Dec 2024 15:41:04 +0100 Subject: [PATCH 2/2] Add tests for the create_partial_model(Model, optional=False) case --- tests/test_optional.py | 85 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test_optional.py diff --git a/tests/test_optional.py b/tests/test_optional.py new file mode 100644 index 0000000..77c4256 --- /dev/null +++ b/tests/test_optional.py @@ -0,0 +1,85 @@ +from typing import Any, Union + +import pydantic + +import pytest + +from pydantic_partial import create_partial_model +from pydantic_partial._compat import PYDANTIC_V1, PYDANTIC_V2 + + +if PYDANTIC_V1: + def _field_is_required( + model: Union[type[pydantic.BaseModel], pydantic.BaseModel], + field_name: str, + ) -> bool: + """Check if a field is required on a pydantic V1 model.""" + # noinspection PyDeprecation + return model.__fields__[field_name].required + + + def _field_get_default( + model: Union[type[pydantic.BaseModel], pydantic.BaseModel], + field_name: str, + ) -> tuple[Any, Any]: + """Return field default info""" + field_info = model.__fields__[field_name] + return field_info.default, field_info.default_factory +elif PYDANTIC_V2: + def _field_is_required( + model: Union[type[pydantic.BaseModel], pydantic.BaseModel], + field_name: str, + ) -> bool: + """Check if a field is required on a pydantic V2 model.""" + return model.model_fields[field_name].is_required() + + + def _field_get_default( + model: Union[type[pydantic.BaseModel], pydantic.BaseModel], + field_name: str, + ) -> tuple[Any, Any]: + """Return field default info""" + field_info = model.model_fields[field_name] + return field_info.default, field_info.default_factory +else: + raise DeprecationWarning("Pydantic has to be in version 1 or 2.") + + +class Something(pydantic.BaseModel): + name: Union[str, None] = "Joe Doe" + something_else_id: int + + +PartialSomething = create_partial_model(Something, optional=False) +PartialSomethingOptional = create_partial_model(Something, optional=True) + + +def test_fields_not_required(): + assert _field_is_required(PartialSomething, "name") is False + assert _field_is_required(PartialSomething, "something_else_id") is False + + +def test_field_defaults(): + assert _field_get_default(PartialSomething, "name") == ("Joe Doe", None) + assert _field_get_default(PartialSomething, "something_else_id") == (None, None) + + +def test_validate_ok(): + # It shouldn't be necessary to check that the right default values end + # up in the models. That should already be done by pydantic's own tests. + # We just check that validation succeeds. + PartialSomething() + PartialSomething(name='Jane Doe') + PartialSomething(name=None) + PartialSomething(something_else_id=42) + PartialSomething(name='Jane Doe', something_else_id=42) + PartialSomething(name=None, something_else_id=42) + + +def test_validate_fail(): + with pytest.raises(pydantic.ValidationError): + PartialSomething(something_else_id=None) + + +def test_validate_optional(): + PartialSomethingOptional(something_else_id=None)