diff --git a/python/pydantic_core/_pydantic_core.pyi b/python/pydantic_core/_pydantic_core.pyi index 17098cca5..4c8066af1 100644 --- a/python/pydantic_core/_pydantic_core.pyi +++ b/python/pydantic_core/_pydantic_core.pyi @@ -306,6 +306,7 @@ class SchemaSerializer: exclude_defaults: bool = False, exclude_none: bool = False, round_trip: bool = False, + sort_keys: bool = False, warnings: bool | Literal['none', 'warn', 'error'] = True, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, @@ -326,6 +327,7 @@ class SchemaSerializer: exclude_defaults: Whether to exclude fields that are equal to their default value. exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. + sort_keys: Whether to sort dictionary keys. If True, all dictionary keys will be sorted alphabetically, including nested dictionaries. warnings: How to handle invalid fields. False/"none" ignores them, True/"warn" logs errors, "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. fallback: A function to call when an unknown value is encountered, @@ -352,6 +354,7 @@ class SchemaSerializer: exclude_defaults: bool = False, exclude_none: bool = False, round_trip: bool = False, + sort_keys: bool = False, warnings: bool | Literal['none', 'warn', 'error'] = True, fallback: Callable[[Any], Any] | None = None, serialize_as_any: bool = False, @@ -371,6 +374,7 @@ class SchemaSerializer: exclude_defaults: Whether to exclude fields that are equal to their default value. exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. + sort_keys: Whether to sort dictionary keys. If True, all dictionary keys will be sorted alphabetically, including nested dictionaries. warnings: How to handle invalid fields. False/"none" ignores them, True/"warn" logs errors, "error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError]. fallback: A function to call when an unknown value is encountered, @@ -398,6 +402,7 @@ def to_json( by_alias: bool = True, exclude_none: bool = False, round_trip: bool = False, + sort_keys: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8', inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', @@ -419,6 +424,7 @@ def to_json( by_alias: Whether to use the alias names of fields. exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. + sort_keys: Whether to sort dictionary keys. If True, all dictionary keys will be sorted alphabetically, including nested dictionaries. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`. inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`. @@ -477,6 +483,7 @@ def to_jsonable_python( by_alias: bool = True, exclude_none: bool = False, round_trip: bool = False, + sort_keys: bool = False, timedelta_mode: Literal['iso8601', 'float'] = 'iso8601', bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8', inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants', @@ -498,6 +505,7 @@ def to_jsonable_python( by_alias: Whether to use the alias names of fields. exclude_none: Whether to exclude fields that have a value of `None`. round_trip: Whether to enable serialization and validation round-trip support. + sort_keys: Whether to sort dictionary keys. If True, all dictionary keys will be sorted alphabetically, including nested dictionaries. timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`. bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`. inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`. diff --git a/python/pydantic_core/core_schema.py b/python/pydantic_core/core_schema.py index f03daac36..b3667c49f 100644 --- a/python/pydantic_core/core_schema.py +++ b/python/pydantic_core/core_schema.py @@ -151,6 +151,9 @@ def serialize_as_any(self) -> bool: ... @property def round_trip(self) -> bool: ... + @property + def sort_keys(self) -> bool: ... + def mode_is_json(self) -> bool: ... def __str__(self) -> str: ... diff --git a/src/errors/validation_exception.rs b/src/errors/validation_exception.rs index 16e89eb68..902f7f856 100644 --- a/src/errors/validation_exception.rs +++ b/src/errors/validation_exception.rs @@ -347,6 +347,7 @@ impl ValidationError { None, false, false, + false, true, None, DuckTypingSerMode::SchemaBased, diff --git a/src/serializers/extra.rs b/src/serializers/extra.rs index 82c432342..cc82e3cbe 100644 --- a/src/serializers/extra.rs +++ b/src/serializers/extra.rs @@ -86,6 +86,7 @@ impl SerializationState { by_alias: Option, exclude_none: bool, round_trip: bool, + sort_keys: bool, serialize_unknown: bool, fallback: Option<&'py Bound<'_, PyAny>>, duck_typing_ser_mode: DuckTypingSerMode, @@ -100,6 +101,7 @@ impl SerializationState { false, exclude_none, round_trip, + sort_keys, &self.config, &self.rec_guard, serialize_unknown, @@ -126,6 +128,7 @@ pub(crate) struct Extra<'a> { pub exclude_defaults: bool, pub exclude_none: bool, pub round_trip: bool, + pub sort_keys: bool, pub config: &'a SerializationConfig, pub rec_guard: &'a SerRecursionState, // the next two are used for union logic @@ -152,6 +155,7 @@ impl<'a> Extra<'a> { exclude_defaults: bool, exclude_none: bool, round_trip: bool, + sort_keys: bool, config: &'a SerializationConfig, rec_guard: &'a SerRecursionState, serialize_unknown: bool, @@ -168,6 +172,7 @@ impl<'a> Extra<'a> { exclude_defaults, exclude_none, round_trip, + sort_keys, config, rec_guard, check: SerCheck::None, @@ -236,6 +241,7 @@ pub(crate) struct ExtraOwned { exclude_defaults: bool, exclude_none: bool, round_trip: bool, + sort_keys: bool, config: SerializationConfig, rec_guard: SerRecursionState, check: SerCheck, @@ -257,6 +263,7 @@ impl ExtraOwned { exclude_defaults: extra.exclude_defaults, exclude_none: extra.exclude_none, round_trip: extra.round_trip, + sort_keys: extra.sort_keys, config: extra.config.clone(), rec_guard: extra.rec_guard.clone(), check: extra.check, @@ -279,6 +286,7 @@ impl ExtraOwned { exclude_defaults: self.exclude_defaults, exclude_none: self.exclude_none, round_trip: self.round_trip, + sort_keys: self.sort_keys, config: &self.config, rec_guard: &self.rec_guard, check: self.check, diff --git a/src/serializers/fields.rs b/src/serializers/fields.rs index b89468d4e..bed43f992 100644 --- a/src/serializers/fields.rs +++ b/src/serializers/fields.rs @@ -18,6 +18,7 @@ use super::filter::SchemaFilter; use super::infer::{infer_json_key, infer_serialize, infer_to_python, SerializeInfer}; use super::shared::PydanticSerializer; use super::shared::{CombinedSerializer, TypeSerializer}; +use super::type_serializers::dict::sort_dict_recursive; /// representation of a field for serialization #[derive(Debug)] @@ -156,57 +157,42 @@ impl GeneralFieldsSerializer { let output_dict = PyDict::new(py); let mut used_req_fields: usize = 0; - // NOTE! we maintain the order of the input dict assuming that's right - for result in main_iter { - let (key, value) = result?; - let key_str = key_str(&key)?; - let op_field = self.fields.get(key_str); - if extra.exclude_none && value.is_none() { - if let Some(field) = op_field { - if field.required { - used_req_fields += 1; - } - } - continue; + if extra.sort_keys { + let mut items = main_iter + .map(|r| -> PyResult<_> { + let (k, v) = r?; + let k_str = key_str(&k)?.to_owned(); + Ok((k_str, k, v)) + }) + .collect::>>()?; + items.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); + + for (key_str, key, value) in items { + self.process_field( + &key_str, + &key, + value, + &output_dict, + include, + exclude, + &extra, + &mut used_req_fields, + )?; } - let field_extra = Extra { - field_name: Some(key_str), - ..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)? { - 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)?; - } - } - - 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)? - } - None => infer_to_python(&value, next_include.as_ref(), next_exclude.as_ref(), &field_extra)?, - }; - output_dict.set_item(key, value)?; - } else if field_extra.check == SerCheck::Strict { - return Err(PydanticSerializationUnexpectedValue::new( - Some(format!("Unexpected field `{key}`")), - field_extra.model_type_name().map(|bound| bound.to_string()), - None, - ) - .to_py_err()); - } + } else { + for result in main_iter { + let (key, value) = result?; + let key_str = key_str(&key)?; + self.process_field( + key_str, + &key, + value, + &output_dict, + include, + exclude, + &extra, + &mut used_req_fields, + )?; } } @@ -229,6 +215,65 @@ impl GeneralFieldsSerializer { } } + #[allow(clippy::too_many_arguments)] + fn process_field<'py>( + &self, + key_str: &str, + key: &Bound<'py, PyAny>, + value: Bound<'py, PyAny>, + output_dict: &Bound<'py, PyDict>, + include: Option<&Bound<'py, PyAny>>, + exclude: Option<&Bound<'py, PyAny>>, + extra: &Extra, + used_req_fields: &mut usize, + ) -> PyResult<()> { + let op_field = self.fields.get(key_str); + if extra.exclude_none && value.is_none() { + if let Some(field) = op_field { + if field.required { + *used_req_fields += 1; + } + } + return Ok(()); + } + let field_extra = Extra { + field_name: Some(key_str), + ..*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)? { + 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)?; + } + } + + 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)? + } + None => infer_to_python(&value, next_include.as_ref(), next_exclude.as_ref(), &field_extra)?, + }; + output_dict.set_item(key, value)?; + } else if field_extra.check == SerCheck::Strict { + return Err(PydanticSerializationUnexpectedValue::new( + Some(format!("Unexpected field `{key}`")), + field_extra.model_type_name().map(|bound| bound.to_string()), + None, + ) + .to_py_err()); + } + } + Ok(()) + } + pub(crate) fn main_serde_serialize<'py, S: serde::ser::Serializer>( &self, main_iter: impl Iterator, Bound<'py, PyAny>)>>, @@ -238,28 +283,75 @@ impl GeneralFieldsSerializer { exclude: Option<&Bound<'py, PyAny>>, extra: Extra, ) -> Result { - // 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 let mut map = serializer.serialize_map(Some(expected_len))?; - for result in main_iter { - let (key, value) = result.map_err(py_err_se_err)?; - if extra.exclude_none && value.is_none() { - continue; + if extra.sort_keys { + let mut items = main_iter + .map(|r| -> PyResult<_> { + let (k, v) = r?; + let k_str = key_str(&k)?.to_owned(); + Ok((k_str, k, v)) + }) + .collect::>>() + .map_err(py_err_se_err)?; + items.sort_by(|(a, _, _), (b, _, _)| a.cmp(b)); + for (key_str, key, value) in items { + self.process_serde_field::(&key_str, &key, &value, &mut map, include, exclude, &extra)?; } - let key_str = key_str(&key).map_err(py_err_se_err)?; - let field_extra = Extra { - field_name: Some(key_str), - ..extra - }; + } else { + for result in main_iter { + let (key, value) = result.map_err(py_err_se_err)?; + if extra.exclude_none && value.is_none() { + continue; + } + let key_str = key_str(&key).map_err(py_err_se_err)?; + self.process_serde_field::(key_str, &key, &value, &mut map, include, exclude, &extra)?; + } + } + Ok(map) + } + + #[allow(clippy::too_many_arguments)] + fn process_serde_field<'py, S: serde::ser::Serializer>( + &self, + key_str: &str, + key: &Bound<'py, PyAny>, + value: &Bound<'py, PyAny>, + map: &mut S::SerializeMap, + include: Option<&Bound<'py, PyAny>>, + exclude: Option<&Bound<'py, PyAny>>, + extra: &Extra, + ) -> Result<(), S::Error> { + if extra.exclude_none && value.is_none() { + return Ok(()); + } - 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)? { + let field_extra = Extra { + field_name: Some(key_str), + ..*extra + }; + + 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)? { + // Get potentially sorted value + if extra.sort_keys { + let sorted_dict = sort_dict_recursive(value.py(), value).map_err(py_err_se_err)?; let s = PydanticSerializer::new( - &value, + sorted_dict.as_ref(), + 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 { + let s = PydanticSerializer::new( + value, serializer, next_include.as_ref(), next_exclude.as_ref(), @@ -269,15 +361,26 @@ impl GeneralFieldsSerializer { map.serialize_entry(&output_key, &s)?; } } - } else if self.mode == FieldsMode::TypedDictAllow { - 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); + } + } else if self.mode == FieldsMode::TypedDictAllow { + let output_key = infer_json_key(key, &field_extra).map_err(py_err_se_err)?; + // Get potentially sorted value + if extra.sort_keys { + let sorted_dict = sort_dict_recursive(value.py(), value).map_err(py_err_se_err)?; + let s = SerializeInfer::new( + sorted_dict.as_ref(), + next_include.as_ref(), + next_exclude.as_ref(), + &field_extra, + ); + map.serialize_entry(&output_key, &s)?; + } else { + let s = SerializeInfer::new(value, next_include.as_ref(), next_exclude.as_ref(), &field_extra); map.serialize_entry(&output_key, &s)?; } - // no error case here since unions (which need the error case) use `to_python(..., mode='json')` } } - Ok(map) + Ok(()) } pub(crate) fn add_computed_fields_python( @@ -424,7 +527,6 @@ impl TypeSerializer for GeneralFieldsSerializer { FieldsMode::TypedDictAllow => main_dict.len() + self.computed_field_count(), _ => 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 let mut map = self.main_serde_serialize( dict_items(&main_dict), @@ -444,8 +546,19 @@ impl TypeSerializer for GeneralFieldsSerializer { let filter = self.filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?; if let Some((next_include, next_exclude)) = filter { let output_key = infer_json_key(&key, extra).map_err(py_err_se_err)?; - let s = SerializeInfer::new(&value, next_include.as_ref(), next_exclude.as_ref(), extra); - map.serialize_entry(&output_key, &s)?; + if extra.sort_keys { + let sorted_dict = sort_dict_recursive(value.py(), &value).map_err(py_err_se_err)?; + let s = SerializeInfer::new( + sorted_dict.as_ref(), + next_include.as_ref(), + next_exclude.as_ref(), + extra, + ); + map.serialize_entry(&output_key, &s)?; + } else { + let s = SerializeInfer::new(&value, next_include.as_ref(), next_exclude.as_ref(), extra); + map.serialize_entry(&output_key, &s)?; + } } } } diff --git a/src/serializers/infer.rs b/src/serializers/infer.rs index 33f53b290..4ecc168b7 100644 --- a/src/serializers/infer.rs +++ b/src/serializers/infer.rs @@ -103,6 +103,7 @@ pub(crate) fn infer_to_python_known( extra.exclude_defaults, extra.exclude_none, extra.round_trip, + extra.sort_keys, extra.rec_guard, extra.serialize_unknown, extra.fallback, @@ -492,6 +493,7 @@ pub(crate) fn infer_serialize_known( extra.exclude_defaults, extra.exclude_none, extra.round_trip, + extra.sort_keys, extra.rec_guard, extra.serialize_unknown, extra.fallback, diff --git a/src/serializers/mod.rs b/src/serializers/mod.rs index 5516f81e1..3155c688e 100644 --- a/src/serializers/mod.rs +++ b/src/serializers/mod.rs @@ -60,6 +60,7 @@ impl SchemaSerializer { exclude_defaults: bool, exclude_none: bool, round_trip: bool, + sort_keys: bool, rec_guard: &'a SerRecursionState, serialize_unknown: bool, fallback: Option<&'a Bound<'a, PyAny>>, @@ -75,6 +76,7 @@ impl SchemaSerializer { exclude_defaults, exclude_none, round_trip, + sort_keys, &self.config, rec_guard, serialize_unknown, @@ -107,8 +109,8 @@ impl SchemaSerializer { #[allow(clippy::too_many_arguments)] #[pyo3(signature = (value, *, mode = None, include = None, exclude = None, by_alias = None, - exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, warnings = WarningsArg::Bool(true), - fallback = None, serialize_as_any = false, context = None))] + exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, sort_keys = false, + warnings = WarningsArg::Bool(true), fallback = None, serialize_as_any = false, context = None))] pub fn to_python( &self, py: Python, @@ -121,6 +123,7 @@ impl SchemaSerializer { exclude_defaults: bool, exclude_none: bool, round_trip: bool, + sort_keys: bool, warnings: WarningsArg, fallback: Option<&Bound<'_, PyAny>>, serialize_as_any: bool, @@ -143,6 +146,7 @@ impl SchemaSerializer { exclude_defaults, exclude_none, round_trip, + sort_keys, &rec_guard, false, fallback, @@ -156,7 +160,8 @@ impl SchemaSerializer { #[allow(clippy::too_many_arguments)] #[pyo3(signature = (value, *, indent = None, include = None, exclude = None, by_alias = None, - exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, warnings = WarningsArg::Bool(true), + exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, + sort_keys = false, warnings = WarningsArg::Bool(true), fallback = None, serialize_as_any = false, context = None))] pub fn to_json( &self, @@ -170,6 +175,7 @@ impl SchemaSerializer { exclude_defaults: bool, exclude_none: bool, round_trip: bool, + sort_keys: bool, warnings: WarningsArg, fallback: Option<&Bound<'_, PyAny>>, serialize_as_any: bool, @@ -191,6 +197,7 @@ impl SchemaSerializer { exclude_defaults, exclude_none, round_trip, + sort_keys, &rec_guard, false, fallback, @@ -240,7 +247,7 @@ impl SchemaSerializer { #[allow(clippy::too_many_arguments)] #[pyfunction] #[pyo3(signature = (value, *, indent = None, include = None, exclude = None, by_alias = true, - exclude_none = false, round_trip = false, timedelta_mode = "iso8601", bytes_mode = "utf8", + exclude_none = false, round_trip = false, sort_keys = false, timedelta_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None, serialize_as_any = false, context = None))] pub fn to_json( @@ -252,6 +259,7 @@ pub fn to_json( by_alias: bool, exclude_none: bool, round_trip: bool, + sort_keys: bool, timedelta_mode: &str, bytes_mode: &str, inf_nan_mode: &str, @@ -268,6 +276,7 @@ pub fn to_json( Some(by_alias), exclude_none, round_trip, + sort_keys, serialize_unknown, fallback, duck_typing_ser_mode, @@ -283,7 +292,7 @@ pub fn to_json( #[allow(clippy::too_many_arguments)] #[pyfunction] #[pyo3(signature = (value, *, include = None, exclude = None, by_alias = true, exclude_none = false, round_trip = false, - timedelta_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None, + sort_keys = false, timedelta_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None, serialize_as_any = false, context = None))] pub fn to_jsonable_python( py: Python, @@ -293,6 +302,7 @@ pub fn to_jsonable_python( by_alias: bool, exclude_none: bool, round_trip: bool, + sort_keys: bool, timedelta_mode: &str, bytes_mode: &str, inf_nan_mode: &str, @@ -309,6 +319,7 @@ pub fn to_jsonable_python( Some(by_alias), exclude_none, round_trip, + sort_keys, serialize_unknown, fallback, duck_typing_ser_mode, diff --git a/src/serializers/type_serializers/dict.rs b/src/serializers/type_serializers/dict.rs index feb45090c..d8adb966b 100644 --- a/src/serializers/type_serializers/dict.rs +++ b/src/serializers/type_serializers/dict.rs @@ -4,6 +4,7 @@ use pyo3::intern; use pyo3::prelude::*; use pyo3::types::PyDict; +use pyo3::types::PyList; use pyo3::IntoPyObjectExt; use serde::ser::SerializeMap; @@ -16,6 +17,30 @@ use super::{ SchemaFilter, SerMode, TypeSerializer, }; +// Add sort_dict_recursive function for reuse by different serializers +pub(crate) fn sort_dict_recursive<'py>(py: Python<'py>, value: &Bound<'py, PyAny>) -> PyResult> { + if let Ok(dict) = value.downcast::() { + let mut items: Vec<(Bound<'py, PyAny>, Bound<'py, PyAny>)> = dict.iter().collect(); + items.sort_by_cached_key(|(key, _)| key.to_string()); + + let sorted_dict = PyDict::new(py); + for (k, v) in items { + let sorted_v = sort_dict_recursive(py, &v)?; + sorted_dict.set_item(k, sorted_v)?; + } + Ok(sorted_dict.into_any()) + } else if let Ok(list) = value.downcast::() { + let sorted_list = PyList::empty(py); + for item in list.iter() { + let sorted_item = sort_dict_recursive(py, &item)?; + sorted_list.append(sorted_item)?; + } + Ok(sorted_list.into_any()) + } else { + Ok(value.clone()) + } +} + #[derive(Debug)] pub struct DictSerializer { key_serializer: Box, @@ -92,8 +117,17 @@ impl TypeSerializer for DictSerializer { SerMode::Json => self.key_serializer.json_key(&key, extra)?.into_py_any(py)?, _ => self.key_serializer.to_python(&key, None, None, extra)?, }; - let value = - value_serializer.to_python(&value, next_include.as_ref(), next_exclude.as_ref(), extra)?; + let value = if extra.sort_keys { + let sorted_value = sort_dict_recursive(py, &value)?; + value_serializer.to_python( + &sorted_value, + next_include.as_ref(), + next_exclude.as_ref(), + extra, + )? + } else { + value_serializer.to_python(&value, next_include.as_ref(), next_exclude.as_ref(), extra)? + }; new_dict.set_item(key, value)?; } } @@ -128,14 +162,26 @@ impl TypeSerializer for DictSerializer { let op_next = self.filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?; if let Some((next_include, next_exclude)) = op_next { let key = key_serializer.json_key(&key, extra).map_err(py_err_se_err)?; - let value_serialize = PydanticSerializer::new( - &value, - value_serializer, - next_include.as_ref(), - next_exclude.as_ref(), - extra, - ); - map.serialize_entry(&key, &value_serialize)?; + if extra.sort_keys { + let sorted_dict = sort_dict_recursive(value.py(), &value).map_err(py_err_se_err)?; + let s = PydanticSerializer::new( + sorted_dict.as_ref(), + value_serializer, + next_include.as_ref(), + next_exclude.as_ref(), + extra, + ); + map.serialize_entry(&key, &s)?; + } else { + let s = PydanticSerializer::new( + &value, + value_serializer, + next_include.as_ref(), + next_exclude.as_ref(), + extra, + ); + map.serialize_entry(&key, &s)?; + } } } map.end() diff --git a/tests/serializers/test_model.py b/tests/serializers/test_model.py index 65871e050..bde27e63f 100644 --- a/tests/serializers/test_model.py +++ b/tests/serializers/test_model.py @@ -12,6 +12,7 @@ import pytest from dirty_equals import IsJson +from inline_snapshot import snapshot from pydantic_core import ( PydanticSerializationError, @@ -1044,6 +1045,244 @@ class MyModel: assert s.to_json(m, exclude={'field_d': [0]}) == b'{"field_a":"test","field_b":12,"field_c":null,"field_d":[2,3]}' +def test_extra_sort_keys(): + class MyModel: + field_123: str + field_b: int + field_a: str + field_c: dict[str, Any] + + schema = core_schema.model_schema( + MyModel, + core_schema.model_fields_schema( + { + 'field_123': core_schema.model_field(core_schema.bytes_schema()), + 'field_b': core_schema.model_field(core_schema.int_schema()), + 'field_a': core_schema.model_field(core_schema.bytes_schema()), + 'field_c': core_schema.model_field(core_schema.dict_schema(core_schema.any_schema())), + 'field_n': core_schema.model_field(core_schema.list_schema(core_schema.any_schema())), + }, + extra_behavior='allow', + ), + extra_behavior='allow', + ) + v = SchemaValidator(schema) + m = v.validate_python( + { + 'field_123': b'test_123', + 'field_b': 12, + 'field_a': b'test', + 'field_c': {'mango': 2, 'banana': 3, 'apple': 1}, + 'field_n': [{'mango': 3, 'banana': 2, 'apple': 1}, 2, 3], + } + ) + s = SchemaSerializer(schema) + assert 'mode:ModelExtra' in plain_repr(s) + assert 'has_extra:true' in plain_repr(s) + assert s.to_python(m, mode='json') == snapshot( + { + 'field_123': 'test_123', + 'field_b': 12, + 'field_a': 'test', + 'field_c': {'mango': 2, 'banana': 3, 'apple': 1}, + 'field_n': [{'mango': 3, 'banana': 2, 'apple': 1}, 2, 3], + } + ) + assert s.to_python(m, mode='json', sort_keys=True) == snapshot( + { + 'field_123': 'test_123', + 'field_a': 'test', + 'field_b': 12, + 'field_c': {'apple': 1, 'banana': 3, 'mango': 2}, + 'field_n': [{'apple': 1, 'banana': 2, 'mango': 3}, 2, 3], + } + ) + assert s.to_json(m) == snapshot( + b'{"field_123":"test_123","field_b":12,"field_a":"test","field_c":{"mango":2,"banana":3,"apple":1},"field_n":[{"mango":3,"banana":2,"apple":1},2,3]}' + ) + assert s.to_json(m, sort_keys=True) == snapshot( + b'{"field_123":"test_123","field_a":"test","field_b":12,"field_c":{"apple":1,"banana":3,"mango":2},"field_n":[{"apple":1,"banana":2,"mango":3},2,3]}' + ) + + # test filterings + m = v.validate_python( + { + 'field_123': b'test_123', + 'field_b': 12, + 'field_a': b'test', + 'field_c': {'mango': 2, 'banana': 3, 'apple': 1}, + 'field_n': [ + {'mango': 3, 'banana': 2, 'apple': 1}, + [{'mango': 3, 'banana': 2, 'apple': 1}, {'d': 3, 'b': 2, 'a': 1}], + 3, + ], + 'field_d': [ + [[{'mango': 3, 'banana': 2, 'apple': 1}], {'d': 3, 'b': 2, 'a': 1}], + 3, + ], + 'field_e': {'c': 1, 'b': {'c': 2, 'd': {'mango': 3, 'banana': 2, 'apple': 1}}, 'a': 3}, + 'field_none': None, + } + ) + assert s.to_python(m, sort_keys=True) == snapshot( + { + 'field_123': b'test_123', + 'field_a': b'test', + 'field_b': 12, + 'field_c': {'apple': 1, 'banana': 3, 'mango': 2}, + 'field_n': [ + {'apple': 1, 'banana': 2, 'mango': 3}, + [{'apple': 1, 'banana': 2, 'mango': 3}, {'a': 1, 'b': 2, 'd': 3}], + 3, + ], + 'field_d': [ + [[{'apple': 1, 'banana': 2, 'mango': 3}], {'a': 1, 'b': 2, 'd': 3}], + 3, + ], + 'field_e': {'a': 3, 'b': {'c': 2, 'd': {'apple': 1, 'banana': 2, 'mango': 3}}, 'c': 1}, + 'field_none': None, + } + ) + assert s.to_python(m, exclude_none=True) == snapshot( + { + 'field_123': b'test_123', + 'field_a': b'test', + 'field_b': 12, + 'field_c': {'mango': 2, 'banana': 3, 'apple': 1}, + 'field_n': [ + {'mango': 3, 'banana': 2, 'apple': 1}, + [{'mango': 3, 'banana': 2, 'apple': 1}, {'d': 3, 'b': 2, 'a': 1}], + 3, + ], + 'field_d': [ + [[{'mango': 3, 'banana': 2, 'apple': 1}], {'d': 3, 'b': 2, 'a': 1}], + 3, + ], + 'field_e': {'c': 1, 'b': {'c': 2, 'd': {'mango': 3, 'banana': 2, 'apple': 1}}, 'a': 3}, + } + ) + assert s.to_python(m, exclude_none=True, sort_keys=True) == snapshot( + { + 'field_123': b'test_123', + 'field_a': b'test', + 'field_b': 12, + 'field_c': {'apple': 1, 'banana': 3, 'mango': 2}, + 'field_n': [ + {'apple': 1, 'banana': 2, 'mango': 3}, + [{'apple': 1, 'banana': 2, 'mango': 3}, {'a': 1, 'b': 2, 'd': 3}], + 3, + ], + 'field_d': [ + [[{'apple': 1, 'banana': 2, 'mango': 3}], {'a': 1, 'b': 2, 'd': 3}], + 3, + ], + 'field_e': {'a': 3, 'b': {'c': 2, 'd': {'apple': 1, 'banana': 2, 'mango': 3}}, 'c': 1}, + } + ) + assert s.to_json(m, exclude_none=True) == snapshot( + b'{"field_123":"test_123","field_b":12,"field_a":"test","field_c":{"mango":2,"banana":3,"apple":1},"field_n":[{"mango":3,"banana":2,"apple":1},[{"mango":3,"banana":2,"apple":1},{"d":3,"b":2,"a":1}],3],"field_d":[[[{"mango":3,"banana":2,"apple":1}],{"d":3,"b":2,"a":1}],3],"field_e":{"c":1,"b":{"c":2,"d":{"mango":3,"banana":2,"apple":1}},"a":3}}' + ) + assert s.to_json(m, exclude_none=True, sort_keys=True) == snapshot( + b'{"field_123":"test_123","field_a":"test","field_b":12,"field_c":{"apple":1,"banana":3,"mango":2},"field_n":[{"apple":1,"banana":2,"mango":3},[{"apple":1,"banana":2,"mango":3},{"a":1,"b":2,"d":3}],3],"field_d":[[[{"apple":1,"banana":2,"mango":3}],{"a":1,"b":2,"d":3}],3],"field_e":{"a":3,"b":{"c":2,"d":{"apple":1,"banana":2,"mango":3}},"c":1}}' + ) + assert s.to_python(m, exclude={'field_c'}) == snapshot( + { + 'field_123': b'test_123', + 'field_a': b'test', + 'field_b': 12, + 'field_n': [ + {'mango': 3, 'banana': 2, 'apple': 1}, + [{'mango': 3, 'banana': 2, 'apple': 1}, {'d': 3, 'b': 2, 'a': 1}], + 3, + ], + 'field_d': [ + [[{'mango': 3, 'banana': 2, 'apple': 1}], {'d': 3, 'b': 2, 'a': 1}], + 3, + ], + 'field_e': {'c': 1, 'b': {'c': 2, 'd': {'mango': 3, 'banana': 2, 'apple': 1}}, 'a': 3}, + 'field_none': None, + } + ) + assert s.to_python(m, exclude={'field_c'}, sort_keys=True) == snapshot( + { + 'field_123': b'test_123', + 'field_a': b'test', + 'field_b': 12, + 'field_n': [ + {'apple': 1, 'banana': 2, 'mango': 3}, + [{'apple': 1, 'banana': 2, 'mango': 3}, {'a': 1, 'b': 2, 'd': 3}], + 3, + ], + 'field_d': [ + [[{'apple': 1, 'banana': 2, 'mango': 3}], {'a': 1, 'b': 2, 'd': 3}], + 3, + ], + 'field_e': {'a': 3, 'b': {'c': 2, 'd': {'apple': 1, 'banana': 2, 'mango': 3}}, 'c': 1}, + 'field_none': None, + } + ) + assert s.to_json(m, exclude={'field_c'}) == snapshot( + b'{"field_123":"test_123","field_b":12,"field_a":"test","field_n":[{"mango":3,"banana":2,"apple":1},[{"mango":3,"banana":2,"apple":1},{"d":3,"b":2,"a":1}],3],"field_d":[[[{"mango":3,"banana":2,"apple":1}],{"d":3,"b":2,"a":1}],3],"field_e":{"c":1,"b":{"c":2,"d":{"mango":3,"banana":2,"apple":1}},"a":3},"field_none":null}' + ) + assert s.to_json(m, exclude={'field_c'}, sort_keys=True) == snapshot( + b'{"field_123":"test_123","field_a":"test","field_b":12,"field_n":[{"apple":1,"banana":2,"mango":3},[{"apple":1,"banana":2,"mango":3},{"a":1,"b":2,"d":3}],3],"field_d":[[[{"apple":1,"banana":2,"mango":3}],{"a":1,"b":2,"d":3}],3],"field_e":{"a":3,"b":{"c":2,"d":{"apple":1,"banana":2,"mango":3}},"c":1},"field_none":null}' + ) + assert s.to_python(m, exclude={'field_d': [0]}) == snapshot( + { + 'field_123': b'test_123', + 'field_a': b'test', + 'field_b': 12, + 'field_c': {'mango': 2, 'banana': 3, 'apple': 1}, + 'field_n': [ + {'mango': 3, 'banana': 2, 'apple': 1}, + [{'mango': 3, 'banana': 2, 'apple': 1}, {'d': 3, 'b': 2, 'a': 1}], + 3, + ], + 'field_d': [3], + 'field_e': {'c': 1, 'b': {'c': 2, 'd': {'mango': 3, 'banana': 2, 'apple': 1}}, 'a': 3}, + 'field_none': None, + } + ) + assert s.to_python(m, exclude={'field_d': [0]}, sort_keys=True) == snapshot( + { + 'field_123': b'test_123', + 'field_a': b'test', + 'field_b': 12, + 'field_c': {'apple': 1, 'banana': 3, 'mango': 2}, + 'field_n': [ + {'apple': 1, 'banana': 2, 'mango': 3}, + [{'apple': 1, 'banana': 2, 'mango': 3}, {'a': 1, 'b': 2, 'd': 3}], + 3, + ], + 'field_d': [3], + 'field_e': {'a': 3, 'b': {'c': 2, 'd': {'apple': 1, 'banana': 2, 'mango': 3}}, 'c': 1}, + 'field_none': None, + } + ) + assert s.to_python(m, exclude={'field_d': [0]}, sort_keys=True, mode='json') == snapshot( + { + 'field_123': 'test_123', + 'field_a': 'test', + 'field_b': 12, + 'field_c': {'apple': 1, 'banana': 3, 'mango': 2}, + 'field_n': [ + {'apple': 1, 'banana': 2, 'mango': 3}, + [{'apple': 1, 'banana': 2, 'mango': 3}, {'a': 1, 'b': 2, 'd': 3}], + 3, + ], + 'field_d': [3], + 'field_e': {'a': 3, 'b': {'c': 2, 'd': {'apple': 1, 'banana': 2, 'mango': 3}}, 'c': 1}, + 'field_none': None, + } + ) + assert s.to_json(m, exclude={'field_d': [0]}) == snapshot( + b'{"field_123":"test_123","field_b":12,"field_a":"test","field_c":{"mango":2,"banana":3,"apple":1},"field_n":[{"mango":3,"banana":2,"apple":1},[{"mango":3,"banana":2,"apple":1},{"d":3,"b":2,"a":1}],3],"field_d":[3],"field_e":{"c":1,"b":{"c":2,"d":{"mango":3,"banana":2,"apple":1}},"a":3},"field_none":null}' + ) + assert s.to_json(m, exclude={'field_d': [0]}, sort_keys=True) == snapshot( + b'{"field_123":"test_123","field_a":"test","field_b":12,"field_c":{"apple":1,"banana":3,"mango":2},"field_n":[{"apple":1,"banana":2,"mango":3},[{"apple":1,"banana":2,"mango":3},{"a":1,"b":2,"d":3}],3],"field_d":[3],"field_e":{"a":3,"b":{"c":2,"d":{"apple":1,"banana":2,"mango":3}},"c":1},"field_none":null}' + ) + + def test_extra_config(): class MyModel: # this is not required, but it avoids `__pydantic_fields_set__` being included in `__dict__` diff --git a/tests/test.rs b/tests/test.rs index 58e2904f5..8124f8f8b 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -100,6 +100,7 @@ a = A() false, false, false, + false, WarningsArg::Bool(true), None, false, @@ -208,6 +209,7 @@ dump_json_input_2 = {'a': 'something'} false, false, false, + false, WarningsArg::Bool(false), None, false, @@ -229,6 +231,7 @@ dump_json_input_2 = {'a': 'something'} false, false, false, + false, WarningsArg::Bool(false), None, false,