Skip to content

Commit 9090e32

Browse files
committed
sort keys
1 parent d8be765 commit 9090e32

File tree

7 files changed

+105
-16
lines changed

7 files changed

+105
-16
lines changed

Cargo.toml

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,18 @@ rust-version = "1.75"
2929
[dependencies]
3030
# TODO it would be very nice to remove the "py-clone" feature as it can panic,
3131
# but needs a bit of work to make sure it's not used in the codebase
32-
pyo3 = { version = "0.23.5", features = ["generate-import-lib", "num-bigint", "py-clone"] }
32+
pyo3 = { version = "0.23.5", features = [
33+
"generate-import-lib",
34+
"num-bigint",
35+
"py-clone",
36+
] }
3337
regex = "1.11.1"
3438
strum = { version = "0.26.3", features = ["derive"] }
3539
strum_macros = "0.26.4"
36-
serde_json = {version = "1.0.138", features = ["arbitrary_precision", "preserve_order"]}
40+
serde_json = { version = "1.0.138", features = [
41+
"arbitrary_precision",
42+
"preserve_order",
43+
] }
3744
enum_dispatch = "0.3.13"
3845
serde = { version = "1.0.218", features = ["derive"] }
3946
speedate = "0.15.0"

python/pydantic_core/_pydantic_core.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ def to_json(
402402
fallback: Callable[[Any], Any] | None = None,
403403
serialize_as_any: bool = False,
404404
context: Any | None = None,
405+
sort_keys: bool = False,
405406
) -> bytes:
406407
"""
407408
Serialize a Python object to JSON including transforming and filtering data.

src/errors/validation_exception.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,7 @@ impl ValidationError {
351351
None,
352352
DuckTypingSerMode::SchemaBased,
353353
None,
354+
false,
354355
);
355356
let serializer = ValidationErrorSerializer {
356357
py,

src/serializers/extra.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,10 @@ impl SerializationState {
8787
exclude_none: bool,
8888
round_trip: bool,
8989
serialize_unknown: bool,
90-
fallback: Option<&'py Bound<'_, PyAny>>,
90+
fallback: Option<&'py Bound<'py, PyAny>>,
9191
duck_typing_ser_mode: DuckTypingSerMode,
92-
context: Option<&'py Bound<'_, PyAny>>,
92+
context: Option<&'py Bound<'py, PyAny>>,
93+
sort_keys: bool,
9394
) -> Extra<'py> {
9495
Extra::new(
9596
py,
@@ -106,6 +107,7 @@ impl SerializationState {
106107
fallback,
107108
duck_typing_ser_mode,
108109
context,
110+
sort_keys,
109111
)
110112
}
111113

@@ -139,6 +141,7 @@ pub(crate) struct Extra<'a> {
139141
pub fallback: Option<&'a Bound<'a, PyAny>>,
140142
pub duck_typing_ser_mode: DuckTypingSerMode,
141143
pub context: Option<&'a Bound<'a, PyAny>>,
144+
pub sort_keys: bool,
142145
}
143146

144147
impl<'a> Extra<'a> {
@@ -158,6 +161,7 @@ impl<'a> Extra<'a> {
158161
fallback: Option<&'a Bound<'a, PyAny>>,
159162
duck_typing_ser_mode: DuckTypingSerMode,
160163
context: Option<&'a Bound<'a, PyAny>>,
164+
sort_keys: bool,
161165
) -> Self {
162166
Self {
163167
mode,
@@ -177,6 +181,7 @@ impl<'a> Extra<'a> {
177181
fallback,
178182
duck_typing_ser_mode,
179183
context,
184+
sort_keys,
180185
}
181186
}
182187

@@ -288,11 +293,12 @@ impl ExtraOwned {
288293
fallback: self.fallback.as_ref().map(|m| m.bind(py)),
289294
duck_typing_ser_mode: self.duck_typing_ser_mode,
290295
context: self.context.as_ref().map(|m| m.bind(py)),
296+
sort_keys: false,
291297
}
292298
}
293299
}
294300

295-
#[derive(Clone)]
301+
#[derive(Clone, PartialEq)]
296302
#[cfg_attr(debug_assertions, derive(Debug))]
297303
pub(crate) enum SerMode {
298304
Python,

src/serializers/infer.rs

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ pub(crate) fn infer_to_python_known(
108108
extra.fallback,
109109
extra.duck_typing_ser_mode,
110110
extra.context,
111+
extra.sort_keys,
111112
);
112113
serializer.serializer.to_python(value, include, exclude, &extra)
113114
};
@@ -265,10 +266,16 @@ pub(crate) fn infer_to_python_known(
265266
}
266267
ObType::Dict => {
267268
let dict = value.downcast::<PyDict>()?;
268-
serialize_pairs_python(py, dict.iter().map(Ok), include, exclude, extra, Ok)?
269+
serialize_pairs_python(py, dict.iter().map(Ok), include, exclude, extra, |k| {
270+
Ok(PyString::new(py, &infer_json_key(&k, extra)?).into_any())
271+
})?
269272
}
270273
ObType::PydanticSerializable => serialize_with_serializer()?,
271-
ObType::Dataclass => serialize_pairs_python(py, any_dataclass_iter(value)?.0, include, exclude, extra, Ok)?,
274+
ObType::Dataclass => {
275+
serialize_pairs_python(py, any_dataclass_iter(value)?.0, include, exclude, extra, |k| {
276+
Ok(PyString::new(py, &infer_json_key(&k, extra)?).into_any())
277+
})?
278+
}
272279
ObType::Generator => {
273280
let iter = super::type_serializers::generator::SerializationIterator::new(
274281
value.downcast()?,
@@ -497,6 +504,7 @@ pub(crate) fn infer_serialize_known<S: Serializer>(
497504
extra.fallback,
498505
extra.duck_typing_ser_mode,
499506
extra.context,
507+
extra.sort_keys,
500508
);
501509
let pydantic_serializer =
502510
PydanticSerializer::new(value, &extracted_serializer.serializer, include, exclude, &extra);
@@ -708,15 +716,36 @@ fn serialize_pairs_python<'py>(
708716
let new_dict = PyDict::new(py);
709717
let filter = AnyFilter::new();
710718

719+
// Collect pairs if we need to sort
720+
let mut pairs = Vec::new();
711721
for result in pairs_iter {
712722
let (k, v) = result?;
713723
let op_next = filter.key_filter(&k, include, exclude)?;
714724
if let Some((next_include, next_exclude)) = op_next {
715-
let k = key_transform(k)?;
725+
let k = if *extra.mode == SerMode::Json {
726+
key_transform(k)?
727+
} else {
728+
k
729+
};
716730
let v = infer_to_python(&v, next_include.as_ref(), next_exclude.as_ref(), extra)?;
717-
new_dict.set_item(k, v)?;
731+
pairs.push((k, v));
718732
}
719733
}
734+
735+
// Sort if requested and in JSON mode
736+
if extra.sort_keys && *extra.mode == SerMode::Json {
737+
pairs.sort_by(|(a, _), (b, _)| {
738+
a.str()
739+
.ok()
740+
.and_then(|s| s.to_str().ok().map(ToString::to_string))
741+
.cmp(&b.str().ok().and_then(|s| s.to_str().ok().map(ToString::to_string)))
742+
});
743+
}
744+
745+
// Add to dictionary
746+
for (k, v) in pairs {
747+
new_dict.set_item(k, v)?;
748+
}
720749
Ok(new_dict.into())
721750
}
722751

@@ -731,15 +760,26 @@ fn serialize_pairs_json<'py, S: Serializer>(
731760
let mut map = serializer.serialize_map(Some(iter_size))?;
732761
let filter = AnyFilter::new();
733762

763+
// If sort_keys is true, collect and sort the pairs first
764+
let mut pairs: Vec<_> = Vec::new();
734765
for result in pairs_iter {
735766
let (key, value) = result.map_err(py_err_se_err)?;
736-
737767
let op_next = filter.key_filter(&key, include, exclude).map_err(py_err_se_err)?;
738768
if let Some((next_include, next_exclude)) = op_next {
739-
let key = infer_json_key(&key, extra).map_err(py_err_se_err)?;
740-
let value_serializer = SerializeInfer::new(&value, next_include.as_ref(), next_exclude.as_ref(), extra);
741-
map.serialize_entry(&key, &value_serializer)?;
769+
let key_str = infer_json_key(&key, extra).map_err(py_err_se_err)?.into_owned();
770+
pairs.push((key_str, (value, next_include, next_exclude)));
742771
}
743772
}
773+
774+
if extra.sort_keys {
775+
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
776+
}
777+
778+
// Serialize the pairs in order
779+
for (key, (value, next_include, next_exclude)) in pairs {
780+
let value_serializer = SerializeInfer::new(&value, next_include.as_ref(), next_exclude.as_ref(), extra);
781+
map.serialize_entry(&key, &value_serializer)?;
782+
}
783+
744784
map.end()
745785
}

src/serializers/mod.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ impl SchemaSerializer {
6565
fallback: Option<&'a Bound<'a, PyAny>>,
6666
duck_typing_ser_mode: DuckTypingSerMode,
6767
context: Option<&'a Bound<'a, PyAny>>,
68+
sort_keys: bool,
6869
) -> Extra<'b> {
6970
Extra::new(
7071
py,
@@ -81,6 +82,7 @@ impl SchemaSerializer {
8182
fallback,
8283
duck_typing_ser_mode,
8384
context,
85+
sort_keys,
8486
)
8587
}
8688
}
@@ -148,6 +150,7 @@ impl SchemaSerializer {
148150
fallback,
149151
duck_typing_ser_mode,
150152
context,
153+
false,
151154
);
152155
let v = self.serializer.to_python(value, include, exclude, &extra)?;
153156
warnings.final_check(py)?;
@@ -157,7 +160,7 @@ impl SchemaSerializer {
157160
#[allow(clippy::too_many_arguments)]
158161
#[pyo3(signature = (value, *, indent = None, include = None, exclude = None, by_alias = None,
159162
exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, warnings = WarningsArg::Bool(true),
160-
fallback = None, serialize_as_any = false, context = None))]
163+
fallback = None, serialize_as_any = false, context = None, sort_keys = false))]
161164
pub fn to_json(
162165
&self,
163166
py: Python,
@@ -174,6 +177,7 @@ impl SchemaSerializer {
174177
fallback: Option<&Bound<'_, PyAny>>,
175178
serialize_as_any: bool,
176179
context: Option<&Bound<'_, PyAny>>,
180+
sort_keys: bool,
177181
) -> PyResult<PyObject> {
178182
let warnings_mode = match warnings {
179183
WarningsArg::Bool(b) => b.into(),
@@ -196,6 +200,7 @@ impl SchemaSerializer {
196200
fallback,
197201
duck_typing_ser_mode,
198202
context,
203+
sort_keys,
199204
);
200205
let bytes = to_json_bytes(
201206
value,
@@ -242,7 +247,7 @@ impl SchemaSerializer {
242247
#[pyo3(signature = (value, *, indent = None, include = None, exclude = None, by_alias = None,
243248
exclude_none = false, round_trip = false, timedelta_mode = "iso8601", bytes_mode = "utf8",
244249
inf_nan_mode = "constants", serialize_unknown = false, fallback = None, serialize_as_any = false,
245-
context = None))]
250+
context = None, sort_keys = false))]
246251
pub fn to_json(
247252
py: Python,
248253
value: &Bound<'_, PyAny>,
@@ -259,6 +264,7 @@ pub fn to_json(
259264
fallback: Option<&Bound<'_, PyAny>>,
260265
serialize_as_any: bool,
261266
context: Option<&Bound<'_, PyAny>>,
267+
sort_keys: bool,
262268
) -> PyResult<PyObject> {
263269
let state = SerializationState::new(timedelta_mode, bytes_mode, inf_nan_mode)?;
264270
let duck_typing_ser_mode = DuckTypingSerMode::from_bool(serialize_as_any);
@@ -272,6 +278,7 @@ pub fn to_json(
272278
fallback,
273279
duck_typing_ser_mode,
274280
context,
281+
sort_keys,
275282
);
276283
let serializer = type_serializers::any::AnySerializer.into();
277284
let bytes = to_json_bytes(value, &serializer, include, exclude, &extra, indent, 1024)?;
@@ -284,7 +291,7 @@ pub fn to_json(
284291
#[pyfunction]
285292
#[pyo3(signature = (value, *, include = None, exclude = None, by_alias = None, exclude_none = false, round_trip = false,
286293
timedelta_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None,
287-
serialize_as_any = false, context = None))]
294+
serialize_as_any = false, context = None, sort_keys = false))]
288295
pub fn to_jsonable_python(
289296
py: Python,
290297
value: &Bound<'_, PyAny>,
@@ -300,6 +307,7 @@ pub fn to_jsonable_python(
300307
fallback: Option<&Bound<'_, PyAny>>,
301308
serialize_as_any: bool,
302309
context: Option<&Bound<'_, PyAny>>,
310+
sort_keys: bool,
303311
) -> PyResult<PyObject> {
304312
let state = SerializationState::new(timedelta_mode, bytes_mode, inf_nan_mode)?;
305313
let duck_typing_ser_mode = DuckTypingSerMode::from_bool(serialize_as_any);
@@ -313,6 +321,7 @@ pub fn to_jsonable_python(
313321
fallback,
314322
duck_typing_ser_mode,
315323
context,
324+
sort_keys,
316325
);
317326
let v = infer::infer_to_python(value, include, exclude, &extra)?;
318327
state.final_check(py)?;

tests/test_json.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,31 @@ def test_to_json_fallback():
233233
assert to_json(Foobar(), fallback=fallback_func) == b'"fallback:Foobar"'
234234

235235

236+
@pytest.mark.parametrize(
237+
'input_value,unsorted_output_value,sorted_output_value',
238+
[
239+
(
240+
{'b': 2, 'a': 1},
241+
b'{"b":2,"a":1}',
242+
b'{"a":1,"b":2}',
243+
),
244+
(
245+
{'b': {'d': 4, 'c': 3}},
246+
b'{"b":{"d":4,"c":3}}',
247+
b'{"b":{"c":3,"d":4}}',
248+
),
249+
(
250+
{'b': {'d': 4, 'c': 3}, 'a': 1},
251+
b'{"b":{"d":4,"c":3},"a":1}',
252+
b'{"a":1,"b":{"c":3,"d":4}}',
253+
),
254+
],
255+
)
256+
def test_to_json_sort_keys(input_value, unsorted_output_value, sorted_output_value):
257+
assert to_json(input_value) == unsorted_output_value
258+
assert to_json(input_value, sort_keys=True) == sorted_output_value
259+
260+
236261
def test_to_jsonable_python():
237262
assert to_jsonable_python([1, 2]) == [1, 2]
238263
assert to_jsonable_python({1, 2}) == IsList(1, 2, check_order=False)

0 commit comments

Comments
 (0)