From f332b8f40e0ac443c279dbc07949642cb56c124c Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Mon, 13 Oct 2025 12:51:05 +0100 Subject: [PATCH 1/8] tests for root model behaviour --- tests/serializers/test_model_root.py | 31 +++++++++++----------- tests/serializers/test_serialize_as_any.py | 25 +++++++++++++++++ 2 files changed, 40 insertions(+), 16 deletions(-) diff --git a/tests/serializers/test_model_root.py b/tests/serializers/test_model_root.py index b663a2f74..109f29262 100644 --- a/tests/serializers/test_model_root.py +++ b/tests/serializers/test_model_root.py @@ -1,5 +1,6 @@ import json import platform +from pathlib import Path from typing import Any, Union import pytest @@ -181,27 +182,25 @@ class Model(RootModel): assert s.to_json(m) == b'[1,2,{"value":"abc"}]' -def test_construct_nested(): - class RModel(RootModel): +def test_not_root_model(): + # https://github.com/pydantic/pydantic/issues/8963 + + class RootModel: root: int - class BModel(BaseModel): - value: RModel + v = RootModel() + v.root = '123' s = SchemaSerializer( core_schema.model_schema( - BModel, - core_schema.model_fields_schema( - { - 'value': core_schema.model_field( - core_schema.model_schema(RModel, core_schema.int_schema(), root_model=True) - ) - } - ), - ) + RootModel, + core_schema.str_schema(), + root_model=True, + ), ) - m = BModel(value=42) + assert s.to_python(v) == '123' + assert s.to_json(v) == b'"123"' - with pytest.raises(AttributeError, match="'int' object has no attribute 'root'"): - s.to_python(m) + assert s.to_python(Path('/a/b')) == '/a/b' + assert s.to_json(Path('/a/b')) == b'"/a/b"' diff --git a/tests/serializers/test_serialize_as_any.py b/tests/serializers/test_serialize_as_any.py index fd378ee61..2af7960fb 100644 --- a/tests/serializers/test_serialize_as_any.py +++ b/tests/serializers/test_serialize_as_any.py @@ -420,3 +420,28 @@ def test_serialize_as_any_with_field_serializer(container_schema_builder) -> Non assert s.to_python(v, serialize_as_any=True) == {'value': 246} assert s.to_json(v, serialize_as_any=False) == b'{"value":246}' assert s.to_json(v, serialize_as_any=True) == b'{"value":246}' + + +def test_serialize_as_any_with_field_serializer_root_model() -> None: + # https://github.com/pydantic/pydantic/issues/12379 + + schema = core_schema.model_schema( + type('Test', (), {}), + core_schema.int_schema( + serialization=core_schema.plain_serializer_function_ser_schema( + lambda model, v: v * 2, is_field_serializer=True + ) + ), + root_model=True, + ) + + v = SchemaValidator(schema).validate_python(123) + cls = type(v) + s = SchemaSerializer(schema) + # necessary to ensure that type inference will pick up the serializer + cls.__pydantic_serializer__ = s + + assert s.to_python(v, serialize_as_any=False) == 246 + assert s.to_python(v, serialize_as_any=True) == 246 + assert s.to_json(v, serialize_as_any=False) == b'246' + assert s.to_json(v, serialize_as_any=True) == b'246' From a2f3452bf5d9d25a7a14b0be141b61736192c5dc Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Mon, 13 Oct 2025 13:43:59 +0100 Subject: [PATCH 2/8] fix various `RootModel` serialization issues --- src/serializers/errors.rs | 9 ++ src/serializers/type_serializers/mod.rs | 2 +- src/serializers/type_serializers/model.rs | 152 +++++++++++++-------- tests/serializers/test_model_root.py | 10 +- tests/serializers/test_serialize_as_any.py | 142 +++++++++---------- 5 files changed, 182 insertions(+), 133 deletions(-) diff --git a/src/serializers/errors.rs b/src/serializers/errors.rs index c7f4db961..43f9a771e 100644 --- a/src/serializers/errors.rs +++ b/src/serializers/errors.rs @@ -18,6 +18,15 @@ pub(super) fn py_err_se_err(py_error: E) -> T { T::custom(py_error.to_string()) } +/// Wrapper type which allows convenient conversion between `PyErr` and `ser::Error` in `?` expressions. +pub(super) struct WrappedSerError(pub T); + +impl From for WrappedSerError { + fn from(py_err: PyErr) -> Self { + WrappedSerError(T::custom(py_err.to_string())) + } +} + #[pyclass(extends=PyValueError, module="pydantic_core._pydantic_core")] #[derive(Debug, Clone)] pub struct PythonSerializerError { diff --git a/src/serializers/type_serializers/mod.rs b/src/serializers/type_serializers/mod.rs index 5fe990382..5a9a490e8 100644 --- a/src/serializers/type_serializers/mod.rs +++ b/src/serializers/type_serializers/mod.rs @@ -32,7 +32,7 @@ pub mod with_default; use super::computed_fields::ComputedFields; use super::config::utf8_py_error; -use super::errors::{py_err_se_err, PydanticSerializationError}; +use super::errors::{py_err_se_err, PydanticSerializationError, WrappedSerError}; use super::extra::{Extra, ExtraOwned, SerCheck, SerMode}; use super::fields::{FieldsMode, GeneralFieldsSerializer, SerField}; use super::filter::{AnyFilter, SchemaFilter}; diff --git a/src/serializers/type_serializers/model.rs b/src/serializers/type_serializers/model.rs index 3d7372321..2525f522c 100644 --- a/src/serializers/type_serializers/model.rs +++ b/src/serializers/type_serializers/model.rs @@ -9,14 +9,16 @@ use ahash::AHashMap; use pyo3::IntoPyObjectExt; use super::{ - infer_json_key, infer_json_key_known, infer_serialize, infer_to_python, py_err_se_err, BuildSerializer, - CombinedSerializer, ComputedFields, Extra, FieldsMode, GeneralFieldsSerializer, ObType, SerCheck, SerField, - TypeSerializer, + infer_json_key, infer_json_key_known, infer_serialize, infer_to_python, BuildSerializer, CombinedSerializer, + ComputedFields, Extra, FieldsMode, GeneralFieldsSerializer, ObType, SerCheck, SerField, TypeSerializer, + WrappedSerError, }; use crate::build_tools::py_schema_err; use crate::build_tools::{py_schema_error_type, ExtraBehavior}; use crate::definitions::DefinitionsBuilder; -use crate::serializers::errors::PydanticSerializationUnexpectedValue; +use crate::serializers::type_serializers::any::AnySerializer; +use crate::serializers::type_serializers::function::FunctionPlainSerializer; +use crate::serializers::type_serializers::function::FunctionWrapSerializer; use crate::tools::SchemaDict; const ROOT_FIELD: &str = "root"; @@ -139,16 +141,77 @@ fn has_extra(schema: &Bound<'_, PyDict>, config: Option<&Bound<'_, PyDict>>) -> impl ModelSerializer { fn allow_value(&self, value: &Bound<'_, PyAny>, extra: &Extra) -> PyResult { - let class = self.class.bind(value.py()); match extra.check { - SerCheck::Strict => Ok(value.get_type().is(class)), - SerCheck::Lax => value.is_instance(class), + SerCheck::Strict => Ok(value.get_type().is(&self.class)), + SerCheck::Lax => value.is_instance(self.class.bind(value.py())), SerCheck::None => value.hasattr(intern!(value.py(), "__dict__")), } } + fn allow_value_root_model(&self, value: &Bound<'_, PyAny>, extra: &Extra) -> PyResult { + match extra.check { + SerCheck::Strict => Ok(value.get_type().is(&self.class)), + SerCheck::Lax | SerCheck::None => value.is_instance(self.class.bind(value.py())), + } + } + + /// Performs serialization for the model. This handles + /// - compatibility checks + /// - extracting the inner value for root models + /// - applying `serialize_as_any` where needed + /// + /// `do_serialize` should be a function which performs the actual serialization, and should not + /// apply any type inference. (`Model` serialization is strongly coupled with its child + /// serializer, and in the few cases where `serialize_as_any` applies, it is handled here.) + /// + /// If the value is not applicable, `do_serialize` will be called with `None` to indicate fallback + /// behaviour should be used. + fn serialize>( + &self, + value: &Bound<'_, PyAny>, + extra: &Extra, + do_serialize: impl FnOnce(Option<(&Arc, &Bound<'_, PyAny>)>, &Extra) -> Result, + ) -> Result { + let model = Some(value); + let model_extra = Extra { model, ..*extra }; + + match self.root_model { + true if self.allow_value_root_model(value, &model_extra)? => { + let root_extra = Extra { + field_name: Some(ROOT_FIELD), + ..model_extra.clone() + }; + let root = value.getattr(intern!(value.py(), ROOT_FIELD))?; + + // for root models, `serialize_as_any` may apply unless a `field_serializer` is used + let serializer = if root_extra.serialize_as_any + && !matches!( + self.serializer.as_ref(), + CombinedSerializer::Function(FunctionPlainSerializer { + is_field_serializer: true, + .. + }) | CombinedSerializer::FunctionWrap(FunctionWrapSerializer { + is_field_serializer: true, + .. + }), + ) { + AnySerializer::get() + } else { + &self.serializer + }; + + do_serialize(Some((serializer, &root)), &root_extra) + } + false if self.allow_value(value, &model_extra)? => { + let inner_value = self.get_inner_value(value, &model_extra)?; + do_serialize(Some((&self.serializer, &inner_value)), &model_extra) + } + _ => do_serialize(None, &model_extra), + } + } + fn get_inner_value<'py>(&self, model: &Bound<'py, PyAny>, extra: &Extra) -> PyResult> { - let py = model.py(); + let py: Python<'_> = model.py(); let mut attrs = model.getattr(intern!(py, "__dict__"))?.downcast_into::()?; if extra.exclude_unset { @@ -184,37 +247,17 @@ impl TypeSerializer for ModelSerializer { exclude: Option<&Bound<'_, PyAny>>, extra: &Extra, ) -> PyResult> { - let model = Some(value); - - let model_extra = Extra { model, ..*extra }; - if self.root_model { - let field_name = Some(ROOT_FIELD); - let root_extra = Extra { - field_name, - ..model_extra - }; - let py = value.py(); - let root = value.getattr(intern!(py, ROOT_FIELD)).map_err(|original_err| { - if root_extra.check.enabled() { - PydanticSerializationUnexpectedValue::new_from_msg(None).to_py_err() - } else { - original_err - } - })?; - self.serializer.to_python(&root, include, exclude, &root_extra) - } else if self.allow_value(value, &model_extra)? { - let inner_value = self.get_inner_value(value, &model_extra)?; - // There is strong coupling between a model serializer and its child, we should - // not fall back to type inference in the middle. - self.serializer - .to_python_no_infer(&inner_value, include, exclude, &model_extra) - } else { - extra.warnings.on_fallback_py(self.get_name(), value, &model_extra)?; - infer_to_python(value, include, exclude, &model_extra) - } + self.serialize(value, extra, |resolved, extra| match resolved { + Some((serializer, value)) => serializer.to_python_no_infer(value, include, exclude, extra), + None => { + extra.warnings.on_fallback_py(self.get_name(), value, extra)?; + infer_to_python(value, include, exclude, extra) + } + }) } fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { + // FIXME: root model in json key position should serialize as inner value? if self.allow_value(key, extra)? { infer_json_key_known(ObType::PydanticSerializable, key, extra) } else { @@ -231,30 +274,19 @@ impl TypeSerializer for ModelSerializer { exclude: Option<&Bound<'_, PyAny>>, extra: &Extra, ) -> Result { - let model = Some(value); - let model_extra = Extra { model, ..*extra }; - if self.root_model { - let field_name = Some(ROOT_FIELD); - let root_extra = Extra { - field_name, - ..model_extra - }; - let py = value.py(); - let root = value.getattr(intern!(py, ROOT_FIELD)).map_err(py_err_se_err)?; - self.serializer - .serde_serialize(&root, serializer, include, exclude, &root_extra) - } else if self.allow_value(value, &model_extra).map_err(py_err_se_err)? { - let inner_value = self.get_inner_value(value, &model_extra).map_err(py_err_se_err)?; - // There is strong coupling between a model serializer and its child, we should - // not fall back to type inference in the midddle. - self.serializer - .serde_serialize_no_infer(&inner_value, serializer, include, exclude, &model_extra) - } else { - extra - .warnings - .on_fallback_ser::(self.get_name(), value, &model_extra)?; - infer_serialize(value, serializer, include, exclude, &model_extra) - } + self.serialize(value, extra, |resolved, extra| match resolved { + Some((cs, value)) => cs + .serde_serialize_no_infer(value, serializer, include, exclude, extra) + .map_err(WrappedSerError), + None => { + extra + .warnings + .on_fallback_ser::(self.get_name(), value, extra) + .map_err(WrappedSerError)?; + infer_serialize(value, serializer, include, exclude, extra).map_err(WrappedSerError) + } + }) + .map_err(|e| e.0) } fn get_name(&self) -> &str { diff --git a/tests/serializers/test_model_root.py b/tests/serializers/test_model_root.py index 109f29262..e2a79319f 100644 --- a/tests/serializers/test_model_root.py +++ b/tests/serializers/test_model_root.py @@ -202,5 +202,11 @@ class RootModel: assert s.to_python(v) == '123' assert s.to_json(v) == b'"123"' - assert s.to_python(Path('/a/b')) == '/a/b' - assert s.to_json(Path('/a/b')) == b'"/a/b"' + with pytest.warns(UserWarning, match=r'PydanticSerializationUnexpectedValue\(Expected `RootModel`'): + assert s.to_python(Path('/a/b')) == Path('/a/b') + + with pytest.warns(UserWarning, match=r'PydanticSerializationUnexpectedValue\(Expected `RootModel`'): + assert s.to_json(Path('/a/b')) == b'"/a/b"' + + assert s.to_python(Path('/a/b'), warnings=False) == Path('/a/b') + assert s.to_json(Path('/a/b'), warnings=False) == b'"/a/b"' diff --git a/tests/serializers/test_serialize_as_any.py b/tests/serializers/test_serialize_as_any.py index 2af7960fb..2d4228fd5 100644 --- a/tests/serializers/test_serialize_as_any.py +++ b/tests/serializers/test_serialize_as_any.py @@ -7,39 +7,43 @@ from pydantic_core import SchemaSerializer, SchemaValidator, core_schema -def test_serialize_as_any_with_models() -> None: - class Parent: - x: int +class ParentModel: + x: int - class Child(Parent): - y: str - Parent.__pydantic_core_schema__ = core_schema.model_schema( - Parent, - core_schema.model_fields_schema( - { - 'x': core_schema.model_field(core_schema.int_schema()), - } - ), - ) - Parent.__pydantic_validator__ = SchemaValidator(Parent.__pydantic_core_schema__) - Parent.__pydantic_serializer__ = SchemaSerializer(Parent.__pydantic_core_schema__) +class ChildModel(ParentModel): + y: str - Child.__pydantic_core_schema__ = core_schema.model_schema( - Child, - core_schema.model_fields_schema( - { - 'x': core_schema.model_field(core_schema.int_schema()), - 'y': core_schema.model_field(core_schema.str_schema()), - } - ), - ) - Child.__pydantic_validator__ = SchemaValidator(Child.__pydantic_core_schema__) - Child.__pydantic_serializer__ = SchemaSerializer(Child.__pydantic_core_schema__) - child = Child.__pydantic_validator__.validate_python({'x': 1, 'y': 'hopefully not a secret'}) - assert Parent.__pydantic_serializer__.to_python(child, serialize_as_any=False) == {'x': 1} - assert Parent.__pydantic_serializer__.to_python(child, serialize_as_any=True) == { +ParentModel.__pydantic_core_schema__ = core_schema.model_schema( + ParentModel, + core_schema.model_fields_schema( + { + 'x': core_schema.model_field(core_schema.int_schema()), + } + ), + ref='ParentModel', +) +ParentModel.__pydantic_validator__ = SchemaValidator(ParentModel.__pydantic_core_schema__) +ParentModel.__pydantic_serializer__ = SchemaSerializer(ParentModel.__pydantic_core_schema__) + +ChildModel.__pydantic_core_schema__ = core_schema.model_schema( + ChildModel, + core_schema.model_fields_schema( + { + 'x': core_schema.model_field(core_schema.int_schema()), + 'y': core_schema.model_field(core_schema.str_schema()), + } + ), +) +ChildModel.__pydantic_validator__ = SchemaValidator(ChildModel.__pydantic_core_schema__) +ChildModel.__pydantic_serializer__ = SchemaSerializer(ChildModel.__pydantic_core_schema__) + + +def test_serialize_as_any_with_models() -> None: + child = ChildModel.__pydantic_validator__.validate_python({'x': 1, 'y': 'hopefully not a secret'}) + assert ParentModel.__pydantic_serializer__.to_python(child, serialize_as_any=False) == {'x': 1} + assert ParentModel.__pydantic_serializer__.to_python(child, serialize_as_any=True) == { 'x': 1, 'y': 'hopefully not a secret', } @@ -274,68 +278,66 @@ class Node: } -def test_serialize_with_custom_type_and_subclasses(): - class Node: - x: int +def test_serialize_as_any_with_root_model_and_subclasses() -> None: + class RModel: + root: ParentModel - Node.__pydantic_core_schema__ = core_schema.model_schema( - Node, - core_schema.model_fields_schema( - { - 'x': core_schema.model_field(core_schema.int_schema()), - } - ), - ref='Node', + RModel.__pydantic_core_schema__ = core_schema.model_schema( + RModel, + ParentModel.__pydantic_core_schema__, + root_model=True, ) - Node.__pydantic_validator__ = SchemaValidator(Node.__pydantic_core_schema__) - Node.__pydantic_serializer__ = SchemaSerializer(Node.__pydantic_core_schema__) + RModel.__pydantic_validator__ = SchemaValidator(RModel.__pydantic_core_schema__) + RModel.__pydantic_serializer__ = SchemaSerializer(RModel.__pydantic_core_schema__) - class NodeSubClass(Node): - y: int + value = RModel.__pydantic_validator__.validate_python({'x': 1}) + value.root = ChildModel.__pydantic_validator__.validate_python({'x': 1, 'y': 'hopefully not a secret'}) - NodeSubClass.__pydantic_core_schema__ = core_schema.model_schema( - NodeSubClass, - core_schema.model_fields_schema( - { - 'x': core_schema.model_field(core_schema.int_schema()), - 'y': core_schema.model_field(core_schema.int_schema()), - } - ), + assert RModel.__pydantic_serializer__.to_python(value, serialize_as_any=False) == {'x': 1} + + assert RModel.__pydantic_serializer__.to_python(value, serialize_as_any=True) == { + 'x': 1, + 'y': 'hopefully not a secret', + } + + assert RModel.__pydantic_serializer__.to_json(value, serialize_as_any=False) == b'{"x":1}' + assert ( + RModel.__pydantic_serializer__.to_json(value, serialize_as_any=True) == b'{"x":1,"y":"hopefully not a secret"}' ) - NodeSubClass.__pydantic_validator__ = SchemaValidator(NodeSubClass.__pydantic_core_schema__) - NodeSubClass.__pydantic_serializer__ = SchemaSerializer(NodeSubClass.__pydantic_core_schema__) + +def test_serialize_with_custom_type_and_subclasses(): class CustomType: - values: list[Node] + value: ParentModel CustomType.__pydantic_core_schema__ = core_schema.model_schema( CustomType, - core_schema.definitions_schema( - core_schema.model_fields_schema( - { - 'values': core_schema.model_field( - core_schema.list_schema(core_schema.definition_reference_schema('Node')) - ), - } - ), - [ - Node.__pydantic_core_schema__, - ], + core_schema.model_fields_schema( + { + 'value': core_schema.model_field(ParentModel.__pydantic_core_schema__), + } ), ) + CustomType.__pydantic_validator__ = SchemaValidator(CustomType.__pydantic_core_schema__) CustomType.__pydantic_serializer__ = SchemaSerializer(CustomType.__pydantic_core_schema__) - value = CustomType.__pydantic_validator__.validate_python({'values': [{'x': 1}, {'x': 2}]}) - value.values.append(NodeSubClass.__pydantic_validator__.validate_python({'x': 3, 'y': 4})) + value = CustomType.__pydantic_validator__.validate_python({'value': {'x': 1}}) + value.value = ChildModel.__pydantic_validator__.validate_python({'x': 1, 'y': 'hopefully not a secret'}) assert CustomType.__pydantic_serializer__.to_python(value, serialize_as_any=False) == { - 'values': [{'x': 1}, {'x': 2}, {'x': 3}], + 'value': {'x': 1}, } assert CustomType.__pydantic_serializer__.to_python(value, serialize_as_any=True) == { - 'values': [{'x': 1}, {'x': 2}, {'x': 3, 'y': 4}], + 'value': {'x': 1, 'y': 'hopefully not a secret'} } + assert CustomType.__pydantic_serializer__.to_json(value, serialize_as_any=False) == b'{"value":{"x":1}}' + assert ( + CustomType.__pydantic_serializer__.to_json(value, serialize_as_any=True) + == b'{"value":{"x":1,"y":"hopefully not a secret"}}' + ) + def test_serialize_as_any_wrap_serializer_applied_once() -> None: # https://github.com/pydantic/pydantic/issues/11139 From d86b0e1943ddf09a69b7bc5deb740423d5deb9b7 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Mon, 13 Oct 2025 14:05:35 +0100 Subject: [PATCH 3/8] Update tests/serializers/test_serialize_as_any.py Co-authored-by: Victorien <65306057+Viicos@users.noreply.github.com> --- tests/serializers/test_serialize_as_any.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/serializers/test_serialize_as_any.py b/tests/serializers/test_serialize_as_any.py index 2d4228fd5..647bd09c1 100644 --- a/tests/serializers/test_serialize_as_any.py +++ b/tests/serializers/test_serialize_as_any.py @@ -425,7 +425,7 @@ def test_serialize_as_any_with_field_serializer(container_schema_builder) -> Non def test_serialize_as_any_with_field_serializer_root_model() -> None: - # https://github.com/pydantic/pydantic/issues/12379 + """https://github.com/pydantic/pydantic/issues/12379.""" schema = core_schema.model_schema( type('Test', (), {}), From 5435229c6b018c99c73fb9c3542c8c085c5b31a5 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Mon, 13 Oct 2025 14:15:06 +0100 Subject: [PATCH 4/8] fix test on `Windows` --- tests/serializers/test_model_root.py | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/tests/serializers/test_model_root.py b/tests/serializers/test_model_root.py index e2a79319f..0252ea3ed 100644 --- a/tests/serializers/test_model_root.py +++ b/tests/serializers/test_model_root.py @@ -1,16 +1,10 @@ import json import platform -from pathlib import Path +from pathlib import PurePosixPath from typing import Any, Union import pytest -try: - from functools import cached_property -except ImportError: - cached_property = None - - from pydantic_core import SchemaSerializer, core_schema from ..conftest import plain_repr @@ -203,10 +197,10 @@ class RootModel: assert s.to_json(v) == b'"123"' with pytest.warns(UserWarning, match=r'PydanticSerializationUnexpectedValue\(Expected `RootModel`'): - assert s.to_python(Path('/a/b')) == Path('/a/b') + assert s.to_python(PurePosixPath('/a/b')) == PurePosixPath('/a/b') with pytest.warns(UserWarning, match=r'PydanticSerializationUnexpectedValue\(Expected `RootModel`'): - assert s.to_json(Path('/a/b')) == b'"/a/b"' + assert s.to_json(PurePosixPath('/a/b')) == b'"/a/b"' - assert s.to_python(Path('/a/b'), warnings=False) == Path('/a/b') - assert s.to_json(Path('/a/b'), warnings=False) == b'"/a/b"' + assert s.to_python(PurePosixPath('/a/b'), warnings=False) == PurePosixPath('/a/b') + assert s.to_json(PurePosixPath('/a/b'), warnings=False) == b'"/a/b"' From 2166b687e6e656c395b8850ce363011e6973431f Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Mon, 13 Oct 2025 14:25:41 +0100 Subject: [PATCH 5/8] fix test on windows, try 2 --- tests/serializers/test_model_root.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/serializers/test_model_root.py b/tests/serializers/test_model_root.py index 0252ea3ed..a822f4488 100644 --- a/tests/serializers/test_model_root.py +++ b/tests/serializers/test_model_root.py @@ -1,6 +1,6 @@ import json import platform -from pathlib import PurePosixPath +from pathlib import PosixPath from typing import Any, Union import pytest @@ -197,10 +197,10 @@ class RootModel: assert s.to_json(v) == b'"123"' with pytest.warns(UserWarning, match=r'PydanticSerializationUnexpectedValue\(Expected `RootModel`'): - assert s.to_python(PurePosixPath('/a/b')) == PurePosixPath('/a/b') + assert s.to_python(PosixPath('/a/b')) == PosixPath('/a/b') with pytest.warns(UserWarning, match=r'PydanticSerializationUnexpectedValue\(Expected `RootModel`'): - assert s.to_json(PurePosixPath('/a/b')) == b'"/a/b"' + assert s.to_json(PosixPath('/a/b')) == b'"/a/b"' - assert s.to_python(PurePosixPath('/a/b'), warnings=False) == PurePosixPath('/a/b') - assert s.to_json(PurePosixPath('/a/b'), warnings=False) == b'"/a/b"' + assert s.to_python(PosixPath('/a/b'), warnings=False) == PosixPath('/a/b') + assert s.to_json(PosixPath('/a/b'), warnings=False) == b'"/a/b"' From 00200d25e1b7398d62fbbde171472f767db913e1 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Mon, 13 Oct 2025 14:33:02 +0100 Subject: [PATCH 6/8] don't bother adjusting `extra` on model fallback to inference --- src/serializers/type_serializers/model.rs | 40 ++++++++++++----------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/serializers/type_serializers/model.rs b/src/serializers/type_serializers/model.rs index 2525f522c..7c3cea3b3 100644 --- a/src/serializers/type_serializers/model.rs +++ b/src/serializers/type_serializers/model.rs @@ -140,16 +140,16 @@ fn has_extra(schema: &Bound<'_, PyDict>, config: Option<&Bound<'_, PyDict>>) -> } impl ModelSerializer { - fn allow_value(&self, value: &Bound<'_, PyAny>, extra: &Extra) -> PyResult { - match extra.check { + fn allow_value(&self, value: &Bound<'_, PyAny>, check: SerCheck) -> PyResult { + match check { SerCheck::Strict => Ok(value.get_type().is(&self.class)), SerCheck::Lax => value.is_instance(self.class.bind(value.py())), SerCheck::None => value.hasattr(intern!(value.py(), "__dict__")), } } - fn allow_value_root_model(&self, value: &Bound<'_, PyAny>, extra: &Extra) -> PyResult { - match extra.check { + fn allow_value_root_model(&self, value: &Bound<'_, PyAny>, check: SerCheck) -> PyResult { + match check { SerCheck::Strict => Ok(value.get_type().is(&self.class)), SerCheck::Lax | SerCheck::None => value.is_instance(self.class.bind(value.py())), } @@ -170,16 +170,14 @@ impl ModelSerializer { &self, value: &Bound<'_, PyAny>, extra: &Extra, - do_serialize: impl FnOnce(Option<(&Arc, &Bound<'_, PyAny>)>, &Extra) -> Result, + do_serialize: impl FnOnce(Option<(&Arc, &Bound<'_, PyAny>, &Extra)>) -> Result, ) -> Result { - let model = Some(value); - let model_extra = Extra { model, ..*extra }; - match self.root_model { - true if self.allow_value_root_model(value, &model_extra)? => { + true if self.allow_value_root_model(value, extra.check)? => { let root_extra = Extra { field_name: Some(ROOT_FIELD), - ..model_extra.clone() + model: Some(value), + ..extra.clone() }; let root = value.getattr(intern!(value.py(), ROOT_FIELD))?; @@ -200,13 +198,17 @@ impl ModelSerializer { &self.serializer }; - do_serialize(Some((serializer, &root)), &root_extra) + do_serialize(Some((serializer, &root, &root_extra))) } - false if self.allow_value(value, &model_extra)? => { + false if self.allow_value(value, extra.check)? => { + let model_extra = Extra { + model: Some(value), + ..extra.clone() + }; let inner_value = self.get_inner_value(value, &model_extra)?; - do_serialize(Some((&self.serializer, &inner_value)), &model_extra) + do_serialize(Some((&self.serializer, &inner_value, &model_extra))) } - _ => do_serialize(None, &model_extra), + _ => do_serialize(None), } } @@ -247,8 +249,8 @@ impl TypeSerializer for ModelSerializer { exclude: Option<&Bound<'_, PyAny>>, extra: &Extra, ) -> PyResult> { - self.serialize(value, extra, |resolved, extra| match resolved { - Some((serializer, value)) => serializer.to_python_no_infer(value, include, exclude, extra), + self.serialize(value, extra, |resolved| match resolved { + Some((serializer, value, extra)) => serializer.to_python_no_infer(value, include, exclude, extra), None => { extra.warnings.on_fallback_py(self.get_name(), value, extra)?; infer_to_python(value, include, exclude, extra) @@ -258,7 +260,7 @@ impl TypeSerializer for ModelSerializer { fn json_key<'a>(&self, key: &'a Bound<'_, PyAny>, extra: &Extra) -> PyResult> { // FIXME: root model in json key position should serialize as inner value? - if self.allow_value(key, extra)? { + if self.allow_value(key, extra.check)? { infer_json_key_known(ObType::PydanticSerializable, key, extra) } else { extra.warnings.on_fallback_py(&self.name, key, extra)?; @@ -274,8 +276,8 @@ impl TypeSerializer for ModelSerializer { exclude: Option<&Bound<'_, PyAny>>, extra: &Extra, ) -> Result { - self.serialize(value, extra, |resolved, extra| match resolved { - Some((cs, value)) => cs + self.serialize(value, extra, |resolved| match resolved { + Some((cs, value, extra)) => cs .serde_serialize_no_infer(value, serializer, include, exclude, extra) .map_err(WrappedSerError), None => { From 6ada18bbc898e886e2e941681ae593e0a433242e Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Mon, 13 Oct 2025 14:57:43 +0100 Subject: [PATCH 7/8] just use os-specific implementation --- tests/serializers/test_model_root.py | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/tests/serializers/test_model_root.py b/tests/serializers/test_model_root.py index a822f4488..d57bfbd01 100644 --- a/tests/serializers/test_model_root.py +++ b/tests/serializers/test_model_root.py @@ -1,6 +1,7 @@ import json +import os import platform -from pathlib import PosixPath +from pathlib import Path from typing import Any, Union import pytest @@ -196,11 +197,21 @@ class RootModel: assert s.to_python(v) == '123' assert s.to_json(v) == b'"123"' + # Path is chosen because it has a .root property + # which could look like a root model in bad implementations + + if os.name == 'nt': + path_value = Path('C:\\a\\b') + path_bytes = b'"C:\\a\\b"' + else: + path_value = Path('/a/b') + path_bytes = b'"/a/b"' + with pytest.warns(UserWarning, match=r'PydanticSerializationUnexpectedValue\(Expected `RootModel`'): - assert s.to_python(PosixPath('/a/b')) == PosixPath('/a/b') + assert s.to_python(path_value) == path_value with pytest.warns(UserWarning, match=r'PydanticSerializationUnexpectedValue\(Expected `RootModel`'): - assert s.to_json(PosixPath('/a/b')) == b'"/a/b"' + assert s.to_json(path_value) == path_bytes - assert s.to_python(PosixPath('/a/b'), warnings=False) == PosixPath('/a/b') - assert s.to_json(PosixPath('/a/b'), warnings=False) == b'"/a/b"' + assert s.to_python(path_value, warnings=False) == path_value + assert s.to_json(path_value, warnings=False) == path_bytes From 309f09ae8757d21d006596c536309a68530bbc5b Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Mon, 13 Oct 2025 15:09:29 +0100 Subject: [PATCH 8/8] windows path bug? --- tests/serializers/test_model_root.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/serializers/test_model_root.py b/tests/serializers/test_model_root.py index d57bfbd01..a8e666e4f 100644 --- a/tests/serializers/test_model_root.py +++ b/tests/serializers/test_model_root.py @@ -202,7 +202,7 @@ class RootModel: if os.name == 'nt': path_value = Path('C:\\a\\b') - path_bytes = b'"C:\\a\\b"' + path_bytes = b'"C:\\\\a\\\\b"' # fixme double escaping? else: path_value = Path('/a/b') path_bytes = b'"/a/b"'