Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 90 additions & 42 deletions src/serializers/fields.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ use smallvec::SmallVec;

use crate::common::missing_sentinel::get_missing_sentinel_object;
use crate::serializers::extra::SerCheck;
use crate::serializers::type_serializers::any::AnySerializer;
use crate::serializers::type_serializers::function::{FunctionPlainSerializer, FunctionWrapSerializer};
use crate::PydanticSerializationUnexpectedValue;

use super::computed_fields::ComputedFields;
Expand Down Expand Up @@ -190,31 +192,26 @@ impl GeneralFieldsSerializer {
..extra
};
if let Some((next_include, next_exclude)) = self.filter.key_filter(&key, include, exclude)? {
if let Some(field) = op_field {
if let Some(ref serializer) = field.serializer {
if exclude_default(&value, &field_extra, serializer)? {
continue;
}
if serialization_exclude_if(field.serialization_exclude_if.as_ref(), &value)? {
continue;
}
let value =
serializer.to_python(&value, next_include.as_ref(), next_exclude.as_ref(), &field_extra)?;
let output_key = field.get_key_py(output_dict.py(), &field_extra);
output_dict.set_item(output_key, value)?;
}
let (key, serializer) = if let Some(field) = op_field {
let serializer = Self::prepare_value(&value, field, &field_extra)?;

if field.required {
used_req_fields += 1;
}
} else if self.mode == FieldsMode::TypedDictAllow {
let value = match &self.extra_serializer {
Some(serializer) => {
serializer.to_python(&value, next_include.as_ref(), next_exclude.as_ref(), &field_extra)?
}
_ => infer_to_python(&value, next_include.as_ref(), next_exclude.as_ref(), &field_extra)?,

let Some(serializer) = serializer else {
continue;
};
output_dict.set_item(key, value)?;

(field.get_key_py(output_dict.py(), &field_extra), serializer)
} else if self.mode == FieldsMode::TypedDictAllow {
let serializer = self
.extra_serializer
.as_ref()
// If using `serialize_as_any`, extras are always inferred
.filter(|_| !extra.serialize_as_any)
.unwrap_or_else(|| AnySerializer::get());
(&key, serializer)
} else if field_extra.check == SerCheck::Strict {
return Err(PydanticSerializationUnexpectedValue::new(
Some(format!("Unexpected field `{key}`")),
Expand All @@ -223,7 +220,18 @@ impl GeneralFieldsSerializer {
None,
)
.to_py_err());
}
} else {
continue;
};

// Use `no_infer` here because the `serialize_as_any` logic has been handled in `prepare_value`
let value = serializer.to_python_no_infer(
&value,
next_include.as_ref(),
next_exclude.as_ref(),
&field_extra,
)?;
output_dict.set_item(key, value)?;
}
}

Expand Down Expand Up @@ -257,7 +265,7 @@ impl GeneralFieldsSerializer {
extra: Extra,
) -> Result<S::SerializeMap, S::Error> {
// NOTE! As above, we maintain the order of the input dict assuming that's right
// we don't both with `used_fields` here because on unions, `to_python(..., mode='json')` is used
// we don't both with `used_req_fields` here because on unions, `to_python(..., mode='json')` is used
let mut map = serializer.serialize_map(Some(expected_len))?;

for result in main_iter {
Expand All @@ -278,26 +286,23 @@ impl GeneralFieldsSerializer {
let filter = self.filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?;
if let Some((next_include, next_exclude)) = filter {
if let Some(field) = self.fields.get(key_str) {
if let Some(ref serializer) = field.serializer {
if exclude_default(&value, &field_extra, serializer).map_err(py_err_se_err)? {
continue;
}
if serialization_exclude_if(field.serialization_exclude_if.as_ref(), &value)
.map_err(py_err_se_err)?
{
continue;
}
let s = PydanticSerializer::new(
&value,
serializer,
next_include.as_ref(),
next_exclude.as_ref(),
&field_extra,
);
let output_key = field.get_key_json(key_str, &field_extra);
map.serialize_entry(&output_key, &s)?;
}
let Some(serializer) = Self::prepare_value(&value, field, &field_extra).map_err(py_err_se_err)?
else {
continue;
};

// Use `no_infer` here because the `serialize_as_any` logic has been handled in `prepare_value`
let s = PydanticSerializer::new_no_infer(
&value,
serializer,
next_include.as_ref(),
next_exclude.as_ref(),
&field_extra,
);
let output_key = field.get_key_json(key_str, &field_extra);
map.serialize_entry(&output_key, &s)?;
} else if self.mode == FieldsMode::TypedDictAllow {
// FIXME: why is `extra_serializer` not used here when `serialize_as_any` is not set?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is probably a bug we should repro and fix in 2.12?

Copy link
Member

@Viicos Viicos Oct 13, 2025

Choose a reason for hiding this comment

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

Here's a MRE:

class Model(BaseModel, extra='allow'):
    a: int
    __pydantic_extra__: dict[str, int]


m = Model(a=1, extra=1)
m.a = 'not_an_int'
m.model_dump_json()  # PydanticSerializationUnexpectedValue warning raised

m.a = 1
m.extra = 'not_an_int'
m.model_dump_json()  # No warning raised

I created pydantic/pydantic#12385. We could backport to 2.12, but this may introduce warnings that will break CI, which is unideal for a patch release (and this wasn't reported by any user yet).

let output_key = infer_json_key(&key, &field_extra).map_err(py_err_se_err)?;
let s = SerializeInfer::new(&value, next_include.as_ref(), next_exclude.as_ref(), &field_extra);
map.serialize_entry(&output_key, &s)?;
Expand All @@ -308,6 +313,49 @@ impl GeneralFieldsSerializer {
Ok(map)
}

/// Gets the serializer to use for a field, applying `serialize_as_any` logic and applying any
/// field-level exclusions
fn prepare_value<'s>(
value: &Bound<'_, PyAny>,
field: &'s SerField,
field_extra: &Extra,
) -> PyResult<Option<&'s Arc<CombinedSerializer>>> {
let Some(serializer) = field.serializer.as_ref() else {
// field excluded at schema level
return Ok(None);
};

if exclude_default(value, field_extra, serializer)? {
return Ok(None);
}

// FIXME: should `exclude_if` be applied to extra fields too?
if serialization_exclude_if(field.serialization_exclude_if.as_ref(), value)? {
return Ok(None);
}
Comment on lines +332 to +335
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This looks like a possible bug which maybe needs changing in 2.13 rather than backporting?

Copy link
Member

@Viicos Viicos Oct 13, 2025

Choose a reason for hiding this comment

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

Are you talking about the FIXME comment?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes

Copy link
Member

Choose a reason for hiding this comment

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

I think it makes sense to support it, regarding backporting I have the same opinion as in #1835 (comment).

Copy link
Contributor Author

Choose a reason for hiding this comment

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


Ok(Some(
if field_extra.serialize_as_any &&
// if serialize_as_any is set, we ensure that field serializers are
// still used, because this would match the `SerializeAsAny` annotation
// on a field
!matches!(
serializer.as_ref(),
CombinedSerializer::Function(FunctionPlainSerializer {
is_field_serializer: true,
..
}) | CombinedSerializer::FunctionWrap(FunctionWrapSerializer {
is_field_serializer: true,
..
})
) {
AnySerializer::get()
} else {
serializer
},
))
}

pub(crate) fn add_computed_fields_python(
&self,
model: Option<&Bound<'_, PyAny>>,
Expand Down Expand Up @@ -425,7 +473,7 @@ impl TypeSerializer for GeneralFieldsSerializer {
_ => self.fields.len() + option_length!(extra_dict) + self.computed_field_count(),
};
// NOTE! As above, we maintain the order of the input dict assuming that's right
// we don't both with `used_fields` here because on unions, `to_python(..., mode='json')` is used
// we don't both with `used_req_fields` here because on unions, `to_python(..., mode='json')` is used
let mut map = self.main_serde_serialize(
dict_items(&main_dict),
expected_len,
Expand Down
25 changes: 24 additions & 1 deletion src/serializers/shared.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use crate::build_tools::py_schema_error_type;
use crate::definitions::DefinitionsBuilder;
use crate::py_gc::PyGcTraverse;
use crate::serializers::ser::PythonSerializer;
use crate::serializers::type_serializers::any::AnySerializer;
use crate::tools::{py_err, SchemaDict};

use super::errors::se_err_py_err;
Expand Down Expand Up @@ -418,6 +419,27 @@ impl<'py> PydanticSerializer<'py> {
include: Option<&'py Bound<'py, PyAny>>,
exclude: Option<&'py Bound<'py, PyAny>>,
extra: &'py Extra<'py>,
) -> Self {
Self {
value,
serializer: if extra.serialize_as_any {
AnySerializer::get()
} else {
serializer
},
include,
exclude,
extra,
}
}

/// Same as above but will not fall back to type inference when `serialize_as_any` is set
pub(crate) fn new_no_infer(
value: &'py Bound<'py, PyAny>,
serializer: &'py CombinedSerializer,
include: Option<&'py Bound<'py, PyAny>>,
exclude: Option<&'py Bound<'py, PyAny>>,
extra: &'py Extra<'py>,
) -> Self {
Self {
value,
Expand All @@ -431,8 +453,9 @@ impl<'py> PydanticSerializer<'py> {

impl Serialize for PydanticSerializer<'_> {
fn serialize<S: serde::ser::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
// inference is handled in the constructor
self.serializer
.serde_serialize(self.value, serializer, self.include, self.exclude, self.extra)
.serde_serialize_no_infer(self.value, serializer, self.include, self.exclude, self.extra)
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/serializers/type_serializers/function.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ pub struct FunctionPlainSerializer {
// fallback serializer - used when when_used decides that this serializer should not be used
fallback_serializer: Option<Arc<CombinedSerializer>>,
when_used: WhenUsed,
is_field_serializer: bool,
pub(crate) is_field_serializer: bool,
info_arg: bool,
}

Expand Down Expand Up @@ -334,7 +334,7 @@ pub struct FunctionWrapSerializer {
function_name: String,
return_serializer: Arc<CombinedSerializer>,
when_used: WhenUsed,
is_field_serializer: bool,
pub(crate) is_field_serializer: bool,
info_arg: bool,
}

Expand Down
52 changes: 51 additions & 1 deletion tests/serializers/test_serialize_as_any.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from dataclasses import dataclass
from typing import Optional
from typing import Callable, Optional

import pytest
from typing_extensions import TypedDict

from pydantic_core import SchemaSerializer, SchemaValidator, core_schema
Expand Down Expand Up @@ -370,3 +371,52 @@ def a_model_serializer(self, handler, info):
assert MyModel.__pydantic_serializer__.to_python(instance, serialize_as_any=True) == {
'a_field_wrapped': {'an_inner_field': 1},
}


@pytest.fixture(params=['model', 'dataclass'])
def container_schema_builder(
request: pytest.FixtureRequest,
) -> Callable[[dict[str, core_schema.CoreSchema]], core_schema.CoreSchema]:
if request.param == 'model':
return lambda fields: core_schema.model_schema(
cls=type('Test', (), {}),
schema=core_schema.model_fields_schema(
fields={k: core_schema.model_field(schema=v) for k, v in fields.items()},
),
)
elif request.param == 'dataclass':
return lambda fields: core_schema.dataclass_schema(
cls=dataclass(type('Test', (), {})),
schema=core_schema.dataclass_args_schema(
'Test',
fields=[core_schema.dataclass_field(name=k, schema=v) for k, v in fields.items()],
),
fields=[k for k in fields.keys()],
)
else:
raise ValueError(f'Unknown container type {request.param}')


def test_serialize_as_any_with_field_serializer(container_schema_builder) -> None:
# https://github.com/pydantic/pydantic/issues/12379

schema = container_schema_builder(
{
'value': core_schema.int_schema(
serialization=core_schema.plain_serializer_function_ser_schema(
lambda model, v: v * 2, is_field_serializer=True
)
)
}
)

v = SchemaValidator(schema).validate_python({'value': 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) == {'value': 246}
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}'
Loading