Skip to content

Commit 7222c8d

Browse files
committed
allow sorting keys on to_json and to_python by passing in sort_keys
1 parent 66c8c58 commit 7222c8d

File tree

9 files changed

+66
-20
lines changed

9 files changed

+66
-20
lines changed

python/pydantic_core/_pydantic_core.pyi

+8
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,7 @@ class SchemaSerializer:
305305
exclude_unset: bool = False,
306306
exclude_defaults: bool = False,
307307
exclude_none: bool = False,
308+
sort_keys: bool = False,
308309
round_trip: bool = False,
309310
warnings: bool | Literal['none', 'warn', 'error'] = True,
310311
fallback: Callable[[Any], Any] | None = None,
@@ -326,6 +327,7 @@ class SchemaSerializer:
326327
exclude_defaults: Whether to exclude fields that are equal to their default value.
327328
exclude_none: Whether to exclude fields that have a value of `None`.
328329
round_trip: Whether to enable serialization and validation round-trip support.
330+
sort_keys: Whether to sort dictionary keys at the root level.
329331
warnings: How to handle invalid fields. False/"none" ignores them, True/"warn" logs errors,
330332
"error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError].
331333
fallback: A function to call when an unknown value is encountered,
@@ -352,6 +354,7 @@ class SchemaSerializer:
352354
exclude_defaults: bool = False,
353355
exclude_none: bool = False,
354356
round_trip: bool = False,
357+
sort_keys: bool = False,
355358
warnings: bool | Literal['none', 'warn', 'error'] = True,
356359
fallback: Callable[[Any], Any] | None = None,
357360
serialize_as_any: bool = False,
@@ -371,6 +374,7 @@ class SchemaSerializer:
371374
exclude_defaults: Whether to exclude fields that are equal to their default value.
372375
exclude_none: Whether to exclude fields that have a value of `None`.
373376
round_trip: Whether to enable serialization and validation round-trip support.
377+
sort_keys: Whether to sort dictionary keys at the root level.
374378
warnings: How to handle invalid fields. False/"none" ignores them, True/"warn" logs errors,
375379
"error" raises a [`PydanticSerializationError`][pydantic_core.PydanticSerializationError].
376380
fallback: A function to call when an unknown value is encountered,
@@ -395,6 +399,7 @@ def to_json(
395399
by_alias: bool | None = None,
396400
exclude_none: bool = False,
397401
round_trip: bool = False,
402+
sort_keys: bool = False,
398403
timedelta_mode: Literal['iso8601', 'float'] = 'iso8601',
399404
bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8',
400405
inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants',
@@ -416,6 +421,7 @@ def to_json(
416421
by_alias: Whether to use the alias names of fields.
417422
exclude_none: Whether to exclude fields that have a value of `None`.
418423
round_trip: Whether to enable serialization and validation round-trip support.
424+
sort_keys: Whether to sort dictionary keys at the root level.
419425
timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`.
420426
bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`.
421427
inf_nan_mode: How to serialize `Infinity`, `-Infinity` and `NaN` values, either `'null'`, `'constants'`, or `'strings'`.
@@ -471,6 +477,7 @@ def to_jsonable_python(
471477
by_alias: bool | None = None,
472478
exclude_none: bool = False,
473479
round_trip: bool = False,
480+
sort_keys: bool = False,
474481
timedelta_mode: Literal['iso8601', 'float'] = 'iso8601',
475482
bytes_mode: Literal['utf8', 'base64', 'hex'] = 'utf8',
476483
inf_nan_mode: Literal['null', 'constants', 'strings'] = 'constants',
@@ -492,6 +499,7 @@ def to_jsonable_python(
492499
by_alias: Whether to use the alias names of fields.
493500
exclude_none: Whether to exclude fields that have a value of `None`.
494501
round_trip: Whether to enable serialization and validation round-trip support.
502+
sort_keys: Whether to sort dictionary keys at the root level.
495503
timedelta_mode: How to serialize `timedelta` objects, either `'iso8601'` or `'float'`.
496504
bytes_mode: How to serialize `bytes` objects, either `'utf8'`, `'base64'`, or `'hex'`.
497505
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
@@ -151,6 +151,9 @@ def serialize_as_any(self) -> bool: ...
151151
@property
152152
def round_trip(self) -> bool: ...
153153

154+
@property
155+
def sort_keys(self) -> bool: ...
156+
154157
def mode_is_json(self) -> bool: ...
155158

156159
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
None,
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
@@ -86,6 +86,7 @@ impl SerializationState {
8686
by_alias: Option<bool>,
8787
exclude_none: bool,
8888
round_trip: bool,
89+
sort_keys: bool,
8990
serialize_unknown: bool,
9091
fallback: Option<&'py Bound<'_, PyAny>>,
9192
duck_typing_ser_mode: DuckTypingSerMode,
@@ -100,6 +101,7 @@ impl SerializationState {
100101
false,
101102
exclude_none,
102103
round_trip,
104+
sort_keys,
103105
&self.config,
104106
&self.rec_guard,
105107
serialize_unknown,
@@ -126,6 +128,7 @@ pub(crate) struct Extra<'a> {
126128
pub exclude_defaults: bool,
127129
pub exclude_none: bool,
128130
pub round_trip: bool,
131+
pub sort_keys: bool,
129132
pub config: &'a SerializationConfig,
130133
pub rec_guard: &'a SerRecursionState,
131134
// the next two are used for union logic
@@ -152,6 +155,7 @@ impl<'a> Extra<'a> {
152155
exclude_defaults: bool,
153156
exclude_none: bool,
154157
round_trip: bool,
158+
sort_keys: bool,
155159
config: &'a SerializationConfig,
156160
rec_guard: &'a SerRecursionState,
157161
serialize_unknown: bool,
@@ -168,6 +172,7 @@ impl<'a> Extra<'a> {
168172
exclude_defaults,
169173
exclude_none,
170174
round_trip,
175+
sort_keys,
171176
config,
172177
rec_guard,
173178
check: SerCheck::None,
@@ -236,6 +241,7 @@ pub(crate) struct ExtraOwned {
236241
exclude_defaults: bool,
237242
exclude_none: bool,
238243
round_trip: bool,
244+
sort_keys: bool,
239245
config: SerializationConfig,
240246
rec_guard: SerRecursionState,
241247
check: SerCheck,
@@ -257,6 +263,7 @@ impl ExtraOwned {
257263
exclude_defaults: extra.exclude_defaults,
258264
exclude_none: extra.exclude_none,
259265
round_trip: extra.round_trip,
266+
sort_keys: extra.sort_keys,
260267
config: extra.config.clone(),
261268
rec_guard: extra.rec_guard.clone(),
262269
check: extra.check,
@@ -279,6 +286,7 @@ impl ExtraOwned {
279286
exclude_defaults: self.exclude_defaults,
280287
exclude_none: self.exclude_none,
281288
round_trip: self.round_trip,
289+
sort_keys: self.sort_keys,
282290
config: &self.config,
283291
rec_guard: &self.rec_guard,
284292
check: self.check,

src/serializers/fields.rs

+11-5
Original file line numberDiff line numberDiff line change
@@ -156,9 +156,12 @@ impl GeneralFieldsSerializer {
156156
let output_dict = PyDict::new(py);
157157
let mut used_req_fields: usize = 0;
158158

159-
// NOTE! we maintain the order of the input dict assuming that's right
160-
for result in main_iter {
161-
let (key, value) = result?;
159+
let mut items = main_iter.collect::<PyResult<Vec<_>>>()?;
160+
if extra.sort_keys {
161+
items.sort_by_cached_key(|(key, _)| key_str(key).unwrap_or_default().to_string());
162+
}
163+
164+
for (key, value) in items {
162165
let key_str = key_str(&key)?;
163166
let op_field = self.fields.get(key_str);
164167
if extra.exclude_none && value.is_none() {
@@ -242,8 +245,11 @@ impl GeneralFieldsSerializer {
242245
// we don't both with `used_fields` here because on unions, `to_python(..., mode='json')` is used
243246
let mut map = serializer.serialize_map(Some(expected_len))?;
244247

245-
for result in main_iter {
246-
let (key, value) = result.map_err(py_err_se_err)?;
248+
let mut items = main_iter.collect::<PyResult<Vec<_>>>().map_err(py_err_se_err)?;
249+
if extra.sort_keys {
250+
items.sort_by_cached_key(|(key, _)| key_str(key).unwrap_or_default().to_string());
251+
}
252+
for (key, value) in items {
247253
if extra.exclude_none && value.is_none() {
248254
continue;
249255
}

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 = None,
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 = None,
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 = None,
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: Option<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 = None, 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: Option<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,
@@ -991,22 +992,22 @@ def test_extra():
991992
class MyModel:
992993
# this is not required, but it avoids `__pydantic_fields_set__` being included in `__dict__`
993994
__slots__ = '__dict__', '__pydantic_fields_set__', '__pydantic_extra__', '__pydantic_private__'
994-
field_a: str
995995
field_b: int
996+
field_a: str
996997

997998
schema = core_schema.model_schema(
998999
MyModel,
9991000
core_schema.model_fields_schema(
10001001
{
1001-
'field_a': core_schema.model_field(core_schema.bytes_schema()),
10021002
'field_b': core_schema.model_field(core_schema.int_schema()),
1003+
'field_a': core_schema.model_field(core_schema.bytes_schema()),
10031004
},
10041005
extra_behavior='allow',
10051006
),
10061007
extra_behavior='allow',
10071008
)
10081009
v = SchemaValidator(schema)
1009-
m = v.validate_python({'field_a': b'test', 'field_b': 12, 'field_c': 'extra'})
1010+
m = v.validate_python({'field_b': 12, 'field_a': b'test', 'field_c': 'extra'})
10101011
assert isinstance(m, MyModel)
10111012
assert m.__dict__ == {'field_a': b'test', 'field_b': 12}
10121013
assert m.__pydantic_extra__ == {'field_c': 'extra'}
@@ -1015,33 +1016,37 @@ class MyModel:
10151016
s = SchemaSerializer(schema)
10161017
assert 'mode:ModelExtra' in plain_repr(s)
10171018
assert 'has_extra:true' in plain_repr(s)
1018-
assert s.to_python(m) == {'field_a': b'test', 'field_b': 12, 'field_c': 'extra'}
1019+
assert s.to_python(m, sort_keys=False) == snapshot({'field_a': b'test', 'field_b': 12, 'field_c': 'extra'})
1020+
assert s.to_json(m, sort_keys=True) == b'{"field_a":"test","field_b":12,"field_c":"extra"}'
10191021
assert s.to_python(m, mode='json') == {'field_a': 'test', 'field_b': 12, 'field_c': 'extra'}
1020-
assert s.to_json(m) == b'{"field_a":"test","field_b":12,"field_c":"extra"}'
10211022

1022-
# test filtering
1023+
# # test filtering
10231024
m = v.validate_python({'field_a': b'test', 'field_b': 12, 'field_c': None, 'field_d': [1, 2, 3]})
10241025
assert isinstance(m, MyModel)
10251026
assert m.__dict__ == {'field_a': b'test', 'field_b': 12}
10261027
assert m.__pydantic_extra__ == {'field_c': None, 'field_d': [1, 2, 3]}
10271028
assert m.__pydantic_fields_set__ == {'field_a', 'field_b', 'field_c', 'field_d'}
10281029

10291030
assert s.to_python(m) == {'field_a': b'test', 'field_b': 12, 'field_c': None, 'field_d': [1, 2, 3]}
1030-
assert s.to_json(m) == b'{"field_a":"test","field_b":12,"field_c":null,"field_d":[1,2,3]}'
1031+
assert s.to_json(m) == b'{"field_b":12,"field_a":"test","field_c":null,"field_d":[1,2,3]}'
10311032

10321033
assert s.to_python(m, exclude_none=True) == {'field_a': b'test', 'field_b': 12, 'field_d': [1, 2, 3]}
1033-
assert s.to_json(m, exclude_none=True) == b'{"field_a":"test","field_b":12,"field_d":[1,2,3]}'
1034+
assert s.to_json(m, exclude_none=True) == b'{"field_b":12,"field_a":"test","field_d":[1,2,3]}'
10341035

10351036
assert s.to_python(m, exclude={'field_c'}) == {'field_a': b'test', 'field_b': 12, 'field_d': [1, 2, 3]}
1036-
assert s.to_json(m, exclude={'field_c'}) == b'{"field_a":"test","field_b":12,"field_d":[1,2,3]}'
1037+
assert s.to_json(m, exclude={'field_c'}) == b'{"field_b":12,"field_a":"test","field_d":[1,2,3]}'
10371038

10381039
assert s.to_python(m, exclude={'field_d': [0]}) == {
10391040
'field_a': b'test',
10401041
'field_b': 12,
10411042
'field_c': None,
10421043
'field_d': [2, 3],
10431044
}
1044-
assert s.to_json(m, exclude={'field_d': [0]}) == b'{"field_a":"test","field_b":12,"field_c":null,"field_d":[2,3]}'
1045+
assert s.to_json(m, exclude={'field_d': [0]}) == b'{"field_b":12,"field_a":"test","field_c":null,"field_d":[2,3]}'
1046+
assert (
1047+
s.to_json(m, exclude={'field_d': [0]}, sort_keys=True)
1048+
== b'{"field_a":"test","field_b":12,"field_c":null,"field_d":[2,3]}'
1049+
)
10451050

10461051

10471052
def test_extra_config():

0 commit comments

Comments
 (0)