Skip to content

Commit da16140

Browse files
Add serialize_as_any runtime flag support (#1194)
Co-authored-by: David Hewitt <[email protected]>
1 parent 1287d22 commit da16140

File tree

14 files changed

+380
-56
lines changed

14 files changed

+380
-56
lines changed

python/pydantic_core/_pydantic_core.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ class SchemaSerializer:
266266
round_trip: bool = False,
267267
warnings: bool = True,
268268
fallback: Callable[[Any], Any] | None = None,
269+
serialize_as_any: bool = False,
269270
context: dict[str, Any] | None = None,
270271
) -> Any:
271272
"""
@@ -286,6 +287,7 @@ class SchemaSerializer:
286287
warnings: Whether to log warnings when invalid fields are encountered.
287288
fallback: A function to call when an unknown value is encountered,
288289
if `None` a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised.
290+
serialize_as_any: Whether to serialize fields with duck-typing serialization behavior.
289291
context: The context to use for serialization, this is passed to functional serializers as
290292
[`info.context`][pydantic_core.core_schema.SerializationInfo.context].
291293
@@ -309,6 +311,7 @@ class SchemaSerializer:
309311
round_trip: bool = False,
310312
warnings: bool = True,
311313
fallback: Callable[[Any], Any] | None = None,
314+
serialize_as_any: bool = False,
312315
context: dict[str, Any] | None = None,
313316
) -> bytes:
314317
"""
@@ -328,6 +331,7 @@ class SchemaSerializer:
328331
warnings: Whether to log warnings when invalid fields are encountered.
329332
fallback: A function to call when an unknown value is encountered,
330333
if `None` a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised.
334+
serialize_as_any: Whether to serialize fields with duck-typing serialization behavior.
331335
context: The context to use for serialization, this is passed to functional serializers as
332336
[`info.context`][pydantic_core.core_schema.SerializationInfo.context].
333337
@@ -352,6 +356,7 @@ def to_json(
352356
inf_nan_mode: Literal['null', 'constants'] = 'constants',
353357
serialize_unknown: bool = False,
354358
fallback: Callable[[Any], Any] | None = None,
359+
serialize_as_any: bool = False,
355360
context: dict[str, Any] | None = None,
356361
) -> bytes:
357362
"""
@@ -374,6 +379,7 @@ def to_json(
374379
`"<Unserializable {value_type} object>"` will be used.
375380
fallback: A function to call when an unknown value is encountered,
376381
if `None` a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised.
382+
serialize_as_any: Whether to serialize fields with duck-typing serialization behavior.
377383
context: The context to use for serialization, this is passed to functional serializers as
378384
[`info.context`][pydantic_core.core_schema.SerializationInfo.context].
379385
@@ -416,6 +422,7 @@ def to_jsonable_python(
416422
inf_nan_mode: Literal['null', 'constants'] = 'constants',
417423
serialize_unknown: bool = False,
418424
fallback: Callable[[Any], Any] | None = None,
425+
serialize_as_any: bool = False,
419426
context: dict[str, Any] | None = None,
420427
) -> Any:
421428
"""
@@ -438,6 +445,7 @@ def to_jsonable_python(
438445
`"<Unserializable {value_type} object>"` will be used.
439446
fallback: A function to call when an unknown value is encountered,
440447
if `None` a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError] error is raised.
448+
serialize_as_any: Whether to serialize fields with duck-typing serialization behavior.
441449
context: The context to use for serialization, this is passed to functional serializers as
442450
[`info.context`][pydantic_core.core_schema.SerializationInfo.context].
443451

python/pydantic_core/core_schema.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ def exclude_defaults(self) -> bool: ...
142142
def exclude_none(self) -> bool: ...
143143

144144
@property
145+
def serialize_as_any(self) -> bool: ...
146+
145147
def round_trip(self) -> bool: ...
146148

147149
def mode_is_json(self) -> bool: ...

src/errors/validation_exception.rs

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::build_tools::py_schema_error_type;
1717
use crate::errors::LocItem;
1818
use crate::get_pydantic_version;
1919
use crate::input::InputType;
20-
use crate::serializers::{SerMode, SerializationState};
20+
use crate::serializers::{DuckTypingSerMode, Extra, SerMode, SerializationState};
2121
use crate::tools::{safe_repr, SchemaDict};
2222

2323
use super::line_error::ValLineError;
@@ -323,7 +323,17 @@ impl ValidationError {
323323
include_input: bool,
324324
) -> PyResult<Bound<'py, PyString>> {
325325
let state = SerializationState::new("iso8601", "utf8", "constants")?;
326-
let extra = state.extra(py, &SerMode::Json, true, false, false, true, None, None);
326+
let extra = state.extra(
327+
py,
328+
&SerMode::Json,
329+
true,
330+
false,
331+
false,
332+
true,
333+
None,
334+
DuckTypingSerMode::SchemaBased,
335+
None,
336+
);
327337
let serializer = ValidationErrorSerializer {
328338
py,
329339
line_errors: &self.line_errors,
@@ -604,7 +614,7 @@ struct ValidationErrorSerializer<'py> {
604614
url_prefix: Option<&'py str>,
605615
include_context: bool,
606616
include_input: bool,
607-
extra: &'py crate::serializers::Extra<'py>,
617+
extra: &'py Extra<'py>,
608618
input_type: &'py InputType,
609619
}
610620

@@ -636,7 +646,7 @@ struct PyLineErrorSerializer<'py> {
636646
url_prefix: Option<&'py str>,
637647
include_context: bool,
638648
include_input: bool,
639-
extra: &'py crate::serializers::Extra<'py>,
649+
extra: &'py Extra<'py>,
640650
input_type: &'py InputType,
641651
}
642652

src/serializers/extra.rs

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,45 @@ pub(crate) struct SerializationState {
2323
config: SerializationConfig,
2424
}
2525

26+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27+
pub enum DuckTypingSerMode {
28+
// Don't check the type of the value, use the type of the schema
29+
SchemaBased,
30+
// Check the type of the value, use the type of the value
31+
NeedsInference,
32+
// We already checked the type of the value
33+
// we don't want to infer again, but if we recurse down
34+
// we do want to flip this back to NeedsInference for the
35+
// fields / keys / items of any inner serializers
36+
Inferred,
37+
}
38+
39+
impl DuckTypingSerMode {
40+
pub fn from_bool(serialize_as_any: bool) -> Self {
41+
if serialize_as_any {
42+
DuckTypingSerMode::NeedsInference
43+
} else {
44+
DuckTypingSerMode::SchemaBased
45+
}
46+
}
47+
48+
pub fn to_bool(self) -> bool {
49+
match self {
50+
DuckTypingSerMode::SchemaBased => false,
51+
DuckTypingSerMode::NeedsInference => true,
52+
DuckTypingSerMode::Inferred => true,
53+
}
54+
}
55+
56+
pub fn next_mode(self) -> Self {
57+
match self {
58+
DuckTypingSerMode::SchemaBased => DuckTypingSerMode::SchemaBased,
59+
DuckTypingSerMode::NeedsInference => DuckTypingSerMode::Inferred,
60+
DuckTypingSerMode::Inferred => DuckTypingSerMode::NeedsInference,
61+
}
62+
}
63+
}
64+
2665
impl SerializationState {
2766
pub fn new(timedelta_mode: &str, bytes_mode: &str, inf_nan_mode: &str) -> PyResult<Self> {
2867
let warnings = CollectWarnings::new(false);
@@ -44,8 +83,9 @@ impl SerializationState {
4483
exclude_none: bool,
4584
round_trip: bool,
4685
serialize_unknown: bool,
47-
fallback: Option<&'py Bound<'py, PyAny>>,
48-
context: Option<&'py Bound<'py, PyAny>>,
86+
fallback: Option<&'py Bound<'_, PyAny>>,
87+
duck_typing_ser_mode: DuckTypingSerMode,
88+
context: Option<&'py Bound<'_, PyAny>>,
4989
) -> Extra<'py> {
5090
Extra::new(
5191
py,
@@ -60,6 +100,7 @@ impl SerializationState {
60100
&self.rec_guard,
61101
serialize_unknown,
62102
fallback,
103+
duck_typing_ser_mode,
63104
context,
64105
)
65106
}
@@ -92,6 +133,7 @@ pub(crate) struct Extra<'a> {
92133
pub field_name: Option<&'a str>,
93134
pub serialize_unknown: bool,
94135
pub fallback: Option<&'a Bound<'a, PyAny>>,
136+
pub duck_typing_ser_mode: DuckTypingSerMode,
95137
pub context: Option<&'a Bound<'a, PyAny>>,
96138
}
97139

@@ -110,6 +152,7 @@ impl<'a> Extra<'a> {
110152
rec_guard: &'a SerRecursionState,
111153
serialize_unknown: bool,
112154
fallback: Option<&'a Bound<'a, PyAny>>,
155+
duck_typing_ser_mode: DuckTypingSerMode,
113156
context: Option<&'a Bound<'a, PyAny>>,
114157
) -> Self {
115158
Self {
@@ -128,6 +171,7 @@ impl<'a> Extra<'a> {
128171
field_name: None,
129172
serialize_unknown,
130173
fallback,
174+
duck_typing_ser_mode,
131175
context,
132176
}
133177
}
@@ -187,6 +231,7 @@ pub(crate) struct ExtraOwned {
187231
field_name: Option<String>,
188232
serialize_unknown: bool,
189233
pub fallback: Option<PyObject>,
234+
duck_typing_ser_mode: DuckTypingSerMode,
190235
pub context: Option<PyObject>,
191236
}
192237

@@ -206,8 +251,9 @@ impl ExtraOwned {
206251
model: extra.model.map(|model| model.clone().into()),
207252
field_name: extra.field_name.map(ToString::to_string),
208253
serialize_unknown: extra.serialize_unknown,
209-
fallback: extra.fallback.map(|fallback| fallback.clone().into()),
210-
context: extra.context.map(|context| context.clone().into()),
254+
fallback: extra.fallback.map(|model| model.clone().into()),
255+
duck_typing_ser_mode: extra.duck_typing_ser_mode,
256+
context: extra.context.map(|model| model.clone().into()),
211257
}
212258
}
213259

@@ -228,6 +274,7 @@ impl ExtraOwned {
228274
field_name: self.field_name.as_deref(),
229275
serialize_unknown: self.serialize_unknown,
230276
fallback: self.fallback.as_ref().map(|m| m.bind(py)),
277+
duck_typing_ser_mode: self.duck_typing_ser_mode,
231278
context: self.context.as_ref().map(|m| m.bind(py)),
232279
}
233280
}

src/serializers/fields.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use serde::ser::SerializeMap;
88
use smallvec::SmallVec;
99

1010
use crate::serializers::extra::SerCheck;
11+
use crate::serializers::DuckTypingSerMode;
1112
use crate::PydanticSerializationUnexpectedValue;
1213

1314
use super::computed_fields::ComputedFields;
@@ -322,15 +323,30 @@ impl TypeSerializer for GeneralFieldsSerializer {
322323
// then do not touch it
323324
// If there is no model, we (a TypedDict) are the model
324325
let model = extra.model.map_or_else(|| Some(value), Some);
325-
let td_extra = Extra { model, ..*extra };
326+
327+
// If there is no model, use duck typing ser logic for TypedDict
328+
// If there is a model, skip this step, as BaseModel and dataclass duck typing
329+
// is handled in their respective serializers
330+
if extra.model.is_none() {
331+
let duck_typing_ser_mode = extra.duck_typing_ser_mode.next_mode();
332+
let td_extra = Extra {
333+
model,
334+
duck_typing_ser_mode,
335+
..*extra
336+
};
337+
if td_extra.duck_typing_ser_mode == DuckTypingSerMode::Inferred {
338+
return infer_to_python(value, include, exclude, &td_extra);
339+
}
340+
}
326341
let (main_dict, extra_dict) = if let Some(main_extra_dict) = self.extract_dicts(value) {
327342
main_extra_dict
328343
} else {
329-
td_extra.warnings.on_fallback_py(self.get_name(), value, &td_extra)?;
330-
return infer_to_python(value, include, exclude, &td_extra);
344+
extra.warnings.on_fallback_py(self.get_name(), value, extra)?;
345+
return infer_to_python(value, include, exclude, extra);
331346
};
332347

333-
let output_dict = self.main_to_python(py, dict_items(&main_dict), include, exclude, td_extra)?;
348+
let output_dict =
349+
self.main_to_python(py, dict_items(&main_dict), include, exclude, Extra { model, ..*extra })?;
334350

335351
// this is used to include `__pydantic_extra__` in serialization on models
336352
if let Some(extra_dict) = extra_dict {
@@ -376,7 +392,21 @@ impl TypeSerializer for GeneralFieldsSerializer {
376392
// then do not touch it
377393
// If there is no model, we (a TypedDict) are the model
378394
let model = extra.model.map_or_else(|| Some(value), Some);
379-
let td_extra = Extra { model, ..*extra };
395+
396+
// If there is no model, use duck typing ser logic for TypedDict
397+
// If there is a model, skip this step, as BaseModel and dataclass duck typing
398+
// is handled in their respective serializers
399+
if extra.model.is_none() {
400+
let duck_typing_ser_mode = extra.duck_typing_ser_mode.next_mode();
401+
let td_extra = Extra {
402+
model,
403+
duck_typing_ser_mode,
404+
..*extra
405+
};
406+
if td_extra.duck_typing_ser_mode == DuckTypingSerMode::Inferred {
407+
return infer_serialize(value, serializer, include, exclude, &td_extra);
408+
}
409+
}
380410
let expected_len = match self.mode {
381411
FieldsMode::TypedDictAllow => main_dict.len() + self.computed_field_count(),
382412
_ => self.fields.len() + option_length!(extra_dict) + self.computed_field_count(),
@@ -389,7 +419,7 @@ impl TypeSerializer for GeneralFieldsSerializer {
389419
serializer,
390420
include,
391421
exclude,
392-
td_extra,
422+
Extra { model, ..*extra },
393423
)?;
394424

395425
// this is used to include `__pydantic_extra__` in serialization on models

src/serializers/infer.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ pub(crate) fn infer_to_python_known(
103103
extra.rec_guard,
104104
extra.serialize_unknown,
105105
extra.fallback,
106+
extra.duck_typing_ser_mode,
106107
extra.context,
107108
);
108109
serializer.serializer.to_python(value, include, exclude, &extra)
@@ -475,6 +476,7 @@ pub(crate) fn infer_serialize_known<S: Serializer>(
475476
extra.rec_guard,
476477
extra.serialize_unknown,
477478
extra.fallback,
479+
extra.duck_typing_ser_mode,
478480
extra.context,
479481
);
480482
let pydantic_serializer =

0 commit comments

Comments
 (0)