Skip to content

Commit 07a31f5

Browse files
committed
allow sorting keys on to_json and to_python by passing in sort_keys
1 parent 2e4134c commit 07a31f5

File tree

9 files changed

+74
-20
lines changed

9 files changed

+74
-20
lines changed

python/pydantic_core/_pydantic_core.pyi

+8
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,7 @@ class SchemaSerializer:
287287
exclude_unset: bool = False,
288288
exclude_defaults: bool = False,
289289
exclude_none: bool = False,
290+
sort_keys: bool = False,
290291
round_trip: bool = False,
291292
warnings: bool | Literal['none', 'warn', 'error'] = True,
292293
fallback: Callable[[Any], Any] | None = None,
@@ -308,6 +309,7 @@ class SchemaSerializer:
308309
exclude_defaults: Whether to exclude fields that are equal to their default value.
309310
exclude_none: Whether to exclude fields that have a value of `None`.
310311
round_trip: Whether to enable serialization and validation round-trip support.
312+
sort_keys: Whether to sort dictionary keys at the root level.
311313
warnings: How to handle invalid fields. False/"none" ignores them, True/"warn" logs errors,
312314
"error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError].
313315
fallback: A function to call when an unknown value is encountered,
@@ -334,6 +336,7 @@ class SchemaSerializer:
334336
exclude_defaults: bool = False,
335337
exclude_none: bool = False,
336338
round_trip: bool = False,
339+
sort_keys: bool = False,
337340
warnings: bool | Literal['none', 'warn', 'error'] = True,
338341
fallback: Callable[[Any], Any] | None = None,
339342
serialize_as_any: bool = False,
@@ -353,6 +356,7 @@ class SchemaSerializer:
353356
exclude_defaults: Whether to exclude fields that are equal to their default value.
354357
exclude_none: Whether to exclude fields that have a value of `None`.
355358
round_trip: Whether to enable serialization and validation round-trip support.
359+
sort_keys: Whether to sort dictionary keys at the root level.
356360
warnings: How to handle invalid fields. False/"none" ignores them, True/"warn" logs errors,
357361
"error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError].
358362
fallback: A function to call when an unknown value is encountered,
@@ -377,6 +381,7 @@ def to_json(
377381
by_alias: bool = True,
378382
exclude_none: bool = False,
379383
round_trip: bool = False,
384+
sort_keys: bool = False,
380385
timedelta_mode: Literal['iso8601', 'float'] = 'iso8601',
381386
bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8',
382387
inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants',
@@ -398,6 +403,7 @@ def to_json(
398403
by_alias: Whether to use the alias names of fields.
399404
exclude_none: Whether to exclude fields that have a value of `None`.
400405
round_trip: Whether to enable serialization and validation round-trip support.
406+
sort_keys: Whether to sort dictionary keys at the root level.
401407
timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`.
402408
bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`.
403409
inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`.
@@ -453,6 +459,7 @@ def to_jsonable_python(
453459
by_alias: bool = True,
454460
exclude_none: bool = False,
455461
round_trip: bool = False,
462+
sort_keys: bool = False,
456463
timedelta_mode: Literal['iso8601', 'float'] = 'iso8601',
457464
bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8',
458465
inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants',
@@ -474,6 +481,7 @@ def to_jsonable_python(
474481
by_alias: Whether to use the alias names of fields.
475482
exclude_none: Whether to exclude fields that have a value of `None`.
476483
round_trip: Whether to enable serialization and validation round-trip support.
484+
sort_keys: Whether to sort dictionary keys at the root level.
477485
timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`.
478486
bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`.
479487
inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`.

python/pydantic_core/core_schema.py

+3
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ def serialize_as_any(self) -> bool: ...
148148
@property
149149
def round_trip(self) -> bool: ...
150150

151+
@property
152+
def sort_keys(self) -> bool: ...
153+
151154
def mode_is_json(self) -> bool: ...
152155

153156
def __str__(self) -> str: ...

src/errors/validation_exception.rs

+1
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,7 @@ impl ValidationError {
347347
true,
348348
false,
349349
false,
350+
false,
350351
true,
351352
None,
352353
DuckTypingSerMode::SchemaBased,

src/serializers/extra.rs

+8
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ impl SerializationState {
8787
by_alias: bool,
8888
exclude_none: bool,
8989
round_trip: bool,
90+
sort_keys: bool,
9091
serialize_unknown: bool,
9192
fallback: Option<&'py Bound<'_, PyAny>>,
9293
duck_typing_ser_mode: DuckTypingSerMode,
@@ -101,6 +102,7 @@ impl SerializationState {
101102
false,
102103
exclude_none,
103104
round_trip,
105+
sort_keys,
104106
&self.config,
105107
&self.rec_guard,
106108
serialize_unknown,
@@ -127,6 +129,7 @@ pub(crate) struct Extra<'a> {
127129
pub exclude_defaults: bool,
128130
pub exclude_none: bool,
129131
pub round_trip: bool,
132+
pub sort_keys: bool,
130133
pub config: &'a SerializationConfig,
131134
pub rec_guard: &'a SerRecursionState,
132135
// the next two are used for union logic
@@ -153,6 +156,7 @@ impl<'a> Extra<'a> {
153156
exclude_defaults: bool,
154157
exclude_none: bool,
155158
round_trip: bool,
159+
sort_keys: bool,
156160
config: &'a SerializationConfig,
157161
rec_guard: &'a SerRecursionState,
158162
serialize_unknown: bool,
@@ -169,6 +173,7 @@ impl<'a> Extra<'a> {
169173
exclude_defaults,
170174
exclude_none,
171175
round_trip,
176+
sort_keys,
172177
config,
173178
rec_guard,
174179
check: SerCheck::None,
@@ -233,6 +238,7 @@ pub(crate) struct ExtraOwned {
233238
exclude_defaults: bool,
234239
exclude_none: bool,
235240
round_trip: bool,
241+
sort_keys: bool,
236242
config: SerializationConfig,
237243
rec_guard: SerRecursionState,
238244
check: SerCheck,
@@ -254,6 +260,7 @@ impl ExtraOwned {
254260
exclude_defaults: extra.exclude_defaults,
255261
exclude_none: extra.exclude_none,
256262
round_trip: extra.round_trip,
263+
sort_keys: extra.sort_keys,
257264
config: extra.config.clone(),
258265
rec_guard: extra.rec_guard.clone(),
259266
check: extra.check,
@@ -276,6 +283,7 @@ impl ExtraOwned {
276283
exclude_defaults: self.exclude_defaults,
277284
exclude_none: self.exclude_none,
278285
round_trip: self.round_trip,
286+
sort_keys: self.sort_keys,
279287
config: &self.config,
280288
rec_guard: &self.rec_guard,
281289
check: self.check,

src/serializers/fields.rs

+19-5
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,16 @@ impl GeneralFieldsSerializer {
154154
let output_dict = PyDict::new(py);
155155
let mut used_req_fields: usize = 0;
156156

157-
// NOTE! we maintain the order of the input dict assuming that's right
158-
for result in main_iter {
159-
let (key, value) = result?;
157+
let mut items = main_iter.collect::<PyResult<Vec<_>>>()?;
158+
if extra.sort_keys {
159+
items.sort_by(|(a, _), (b, _)| {
160+
let a_str = key_str(a).unwrap_or_default();
161+
let b_str = key_str(b).unwrap_or_default();
162+
a_str.cmp(b_str)
163+
});
164+
}
165+
166+
for (key, value) in items {
160167
let key_str = key_str(&key)?;
161168
let op_field = self.fields.get(key_str);
162169
if extra.exclude_none && value.is_none() {
@@ -246,8 +253,15 @@ impl GeneralFieldsSerializer {
246253
// we don't both with `used_fields` here because on unions, `to_python(..., mode='json')` is used
247254
let mut map = serializer.serialize_map(Some(expected_len))?;
248255

249-
for result in main_iter {
250-
let (key, value) = result.map_err(py_err_se_err)?;
256+
let mut items = main_iter.collect::<PyResult<Vec<_>>>().map_err(py_err_se_err)?;
257+
if extra.sort_keys {
258+
items.sort_by(|(a, _), (b, _)| {
259+
let a_str = key_str(a).unwrap_or_default();
260+
let b_str = key_str(b).unwrap_or_default();
261+
a_str.cmp(b_str)
262+
});
263+
}
264+
for (key, value) in items {
251265
if extra.exclude_none && value.is_none() {
252266
continue;
253267
}

src/serializers/infer.rs

+2
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ pub(crate) fn infer_to_python_known(
103103
extra.exclude_defaults,
104104
extra.exclude_none,
105105
extra.round_trip,
106+
extra.sort_keys,
106107
extra.rec_guard,
107108
extra.serialize_unknown,
108109
extra.fallback,
@@ -492,6 +493,7 @@ pub(crate) fn infer_serialize_known<S: Serializer>(
492493
extra.exclude_defaults,
493494
extra.exclude_none,
494495
extra.round_trip,
496+
extra.sort_keys,
495497
extra.rec_guard,
496498
extra.serialize_unknown,
497499
extra.fallback,

src/serializers/mod.rs

+15-5
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ impl SchemaSerializer {
6060
exclude_defaults: bool,
6161
exclude_none: bool,
6262
round_trip: bool,
63+
sort_keys: bool,
6364
rec_guard: &'a SerRecursionState,
6465
serialize_unknown: bool,
6566
fallback: Option<&'a Bound<'a, PyAny>>,
@@ -75,6 +76,7 @@ impl SchemaSerializer {
7576
exclude_defaults,
7677
exclude_none,
7778
round_trip,
79+
sort_keys,
7880
&self.config,
7981
rec_guard,
8082
serialize_unknown,
@@ -107,8 +109,8 @@ impl SchemaSerializer {
107109

108110
#[allow(clippy::too_many_arguments)]
109111
#[pyo3(signature = (value, *, mode = None, include = None, exclude = None, by_alias = true,
110-
exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, warnings = WarningsArg::Bool(true),
111-
fallback = None, serialize_as_any = false, context = None))]
112+
exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, sort_keys = false,
113+
warnings = WarningsArg::Bool(true), fallback = None, serialize_as_any = false, context = None))]
112114
pub fn to_python(
113115
&self,
114116
py: Python,
@@ -121,6 +123,7 @@ impl SchemaSerializer {
121123
exclude_defaults: bool,
122124
exclude_none: bool,
123125
round_trip: bool,
126+
sort_keys: bool,
124127
warnings: WarningsArg,
125128
fallback: Option<&Bound<'_, PyAny>>,
126129
serialize_as_any: bool,
@@ -143,6 +146,7 @@ impl SchemaSerializer {
143146
exclude_defaults,
144147
exclude_none,
145148
round_trip,
149+
sort_keys,
146150
&rec_guard,
147151
false,
148152
fallback,
@@ -156,7 +160,7 @@ impl SchemaSerializer {
156160

157161
#[allow(clippy::too_many_arguments)]
158162
#[pyo3(signature = (value, *, indent = None, include = None, exclude = None, by_alias = true,
159-
exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, warnings = WarningsArg::Bool(true),
163+
exclude_unset = false, exclude_defaults = false, exclude_none = false, round_trip = false, sort_keys = false, warnings = WarningsArg::Bool(true),
160164
fallback = None, serialize_as_any = false, context = None))]
161165
pub fn to_json(
162166
&self,
@@ -170,6 +174,7 @@ impl SchemaSerializer {
170174
exclude_defaults: bool,
171175
exclude_none: bool,
172176
round_trip: bool,
177+
sort_keys: bool,
173178
warnings: WarningsArg,
174179
fallback: Option<&Bound<'_, PyAny>>,
175180
serialize_as_any: bool,
@@ -191,6 +196,7 @@ impl SchemaSerializer {
191196
exclude_defaults,
192197
exclude_none,
193198
round_trip,
199+
sort_keys,
194200
&rec_guard,
195201
false,
196202
fallback,
@@ -240,7 +246,7 @@ impl SchemaSerializer {
240246
#[allow(clippy::too_many_arguments)]
241247
#[pyfunction]
242248
#[pyo3(signature = (value, *, indent = None, include = None, exclude = None, by_alias = true,
243-
exclude_none = false, round_trip = false, timedelta_mode = "iso8601", bytes_mode = "utf8",
249+
exclude_none = false, round_trip = false, sort_keys = false, timedelta_mode = "iso8601", bytes_mode = "utf8",
244250
inf_nan_mode = "constants", serialize_unknown = false, fallback = None, serialize_as_any = false,
245251
context = None))]
246252
pub fn to_json(
@@ -252,6 +258,7 @@ pub fn to_json(
252258
by_alias: bool,
253259
exclude_none: bool,
254260
round_trip: bool,
261+
sort_keys: bool,
255262
timedelta_mode: &str,
256263
bytes_mode: &str,
257264
inf_nan_mode: &str,
@@ -268,6 +275,7 @@ pub fn to_json(
268275
by_alias,
269276
exclude_none,
270277
round_trip,
278+
sort_keys,
271279
serialize_unknown,
272280
fallback,
273281
duck_typing_ser_mode,
@@ -283,7 +291,7 @@ pub fn to_json(
283291
#[allow(clippy::too_many_arguments)]
284292
#[pyfunction]
285293
#[pyo3(signature = (value, *, include = None, exclude = None, by_alias = true, exclude_none = false, round_trip = false,
286-
timedelta_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None,
294+
sort_keys = false, timedelta_mode = "iso8601", bytes_mode = "utf8", inf_nan_mode = "constants", serialize_unknown = false, fallback = None,
287295
serialize_as_any = false, context = None))]
288296
pub fn to_jsonable_python(
289297
py: Python,
@@ -293,6 +301,7 @@ pub fn to_jsonable_python(
293301
by_alias: bool,
294302
exclude_none: bool,
295303
round_trip: bool,
304+
sort_keys: bool,
296305
timedelta_mode: &str,
297306
bytes_mode: &str,
298307
inf_nan_mode: &str,
@@ -309,6 +318,7 @@ pub fn to_jsonable_python(
309318
by_alias,
310319
exclude_none,
311320
round_trip,
321+
sort_keys,
312322
serialize_unknown,
313323
fallback,
314324
duck_typing_ser_mode,

tests/serializers/test_model.py

+15-10
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import pytest
1414
from dirty_equals import IsJson
15+
from inline_snapshot import snapshot
1516

1617
from pydantic_core import (
1718
PydanticSerializationError,
@@ -960,22 +961,22 @@ def test_extra():
960961
class MyModel:
961962
# this is not required, but it avoids `__pydantic_fields_set__` being included in `__dict__`
962963
__slots__ = '__dict__', '__pydantic_fields_set__', '__pydantic_extra__', '__pydantic_private__'
963-
field_a: str
964964
field_b: int
965+
field_a: str
965966

966967
schema = core_schema.model_schema(
967968
MyModel,
968969
core_schema.model_fields_schema(
969970
{
970-
'field_a': core_schema.model_field(core_schema.bytes_schema()),
971971
'field_b': core_schema.model_field(core_schema.int_schema()),
972+
'field_a': core_schema.model_field(core_schema.bytes_schema()),
972973
},
973974
extra_behavior='allow',
974975
),
975976
extra_behavior='allow',
976977
)
977978
v = SchemaValidator(schema)
978-
m = v.validate_python({'field_a': b'test', 'field_b': 12, 'field_c': 'extra'})
979+
m = v.validate_python({'field_b': 12, 'field_a': b'test', 'field_c': 'extra'})
979980
assert isinstance(m, MyModel)
980981
assert m.__dict__ == {'field_a': b'test', 'field_b': 12}
981982
assert m.__pydantic_extra__ == {'field_c': 'extra'}
@@ -984,33 +985,37 @@ class MyModel:
984985
s = SchemaSerializer(schema)
985986
assert 'mode:ModelExtra' in plain_repr(s)
986987
assert 'has_extra:true' in plain_repr(s)
987-
assert s.to_python(m) == {'field_a': b'test', 'field_b': 12, 'field_c': 'extra'}
988+
assert s.to_python(m, sort_keys=False) == snapshot({'field_a': b'test', 'field_b': 12, 'field_c': 'extra'})
989+
assert s.to_json(m, sort_keys=True) == b'{"field_a":"test","field_b":12,"field_c":"extra"}'
988990
assert s.to_python(m, mode='json') == {'field_a': 'test', 'field_b': 12, 'field_c': 'extra'}
989-
assert s.to_json(m) == b'{"field_a":"test","field_b":12,"field_c":"extra"}'
990991

991-
# test filtering
992+
# # test filtering
992993
m = v.validate_python({'field_a': b'test', 'field_b': 12, 'field_c': None, 'field_d': [1, 2, 3]})
993994
assert isinstance(m, MyModel)
994995
assert m.__dict__ == {'field_a': b'test', 'field_b': 12}
995996
assert m.__pydantic_extra__ == {'field_c': None, 'field_d': [1, 2, 3]}
996997
assert m.__pydantic_fields_set__ == {'field_a', 'field_b', 'field_c', 'field_d'}
997998

998999
assert s.to_python(m) == {'field_a': b'test', 'field_b': 12, 'field_c': None, 'field_d': [1, 2, 3]}
999-
assert s.to_json(m) == b'{"field_a":"test","field_b":12,"field_c":null,"field_d":[1,2,3]}'
1000+
assert s.to_json(m) == b'{"field_b":12,"field_a":"test","field_c":null,"field_d":[1,2,3]}'
10001001

10011002
assert s.to_python(m, exclude_none=True) == {'field_a': b'test', 'field_b': 12, 'field_d': [1, 2, 3]}
1002-
assert s.to_json(m, exclude_none=True) == b'{"field_a":"test","field_b":12,"field_d":[1,2,3]}'
1003+
assert s.to_json(m, exclude_none=True) == b'{"field_b":12,"field_a":"test","field_d":[1,2,3]}'
10031004

10041005
assert s.to_python(m, exclude={'field_c'}) == {'field_a': b'test', 'field_b': 12, 'field_d': [1, 2, 3]}
1005-
assert s.to_json(m, exclude={'field_c'}) == b'{"field_a":"test","field_b":12,"field_d":[1,2,3]}'
1006+
assert s.to_json(m, exclude={'field_c'}) == b'{"field_b":12,"field_a":"test","field_d":[1,2,3]}'
10061007

10071008
assert s.to_python(m, exclude={'field_d': [0]}) == {
10081009
'field_a': b'test',
10091010
'field_b': 12,
10101011
'field_c': None,
10111012
'field_d': [2, 3],
10121013
}
1013-
assert s.to_json(m, exclude={'field_d': [0]}) == b'{"field_a":"test","field_b":12,"field_c":null,"field_d":[2,3]}'
1014+
assert s.to_json(m, exclude={'field_d': [0]}) == b'{"field_b":12,"field_a":"test","field_c":null,"field_d":[2,3]}'
1015+
assert (
1016+
s.to_json(m, exclude={'field_d': [0]}, sort_keys=True)
1017+
== b'{"field_a":"test","field_b":12,"field_c":null,"field_d":[2,3]}'
1018+
)
10141019

10151020

10161021
def test_extra_config():

0 commit comments

Comments
 (0)