11import logging
2- from datetime import datetime , timedelta
2+ from datetime import timedelta
33from decimal import Decimal
44from functools import lru_cache
55from typing import Any , Dict , List , Tuple
2424
2525import orjson
2626
27- from .utils import is_non_string_sequence
27+ from .utils import is_int , is_non_string_sequence
2828
2929
3030try :
@@ -278,14 +278,14 @@ def _fix_floats(current: Dict, data: Dict = None, paths: List = None) -> None:
278278 _fix_floats (val , data , paths = paths )
279279 paths .pop ()
280280 elif isinstance (current , list ):
281- for ( idx , item ) in enumerate (current ):
281+ for idx , item in enumerate (current ):
282282 paths .append (idx )
283283 _fix_floats (item , data , paths = paths )
284284 paths .pop ()
285285 elif isinstance (current , float ):
286286 _piece = data
287287
288- for ( idx , path ) in enumerate (paths ):
288+ for idx , path in enumerate (paths ):
289289 if idx == len (paths ) - 1 :
290290 # `path` can be a dictionary key or list index,
291291 # but in either instance it is set the same way
@@ -294,20 +294,31 @@ def _fix_floats(current: Dict, data: Dict = None, paths: List = None) -> None:
294294 _piece = _piece [path ]
295295
296296
297- @lru_cache (maxsize = 128 , typed = True )
298- def _dumps (
299- serialized_data : bytes , exclude_field_attributes : Tuple [str ] = None
300- ) -> bytes :
297+ def _sort_dict (data : Dict ) -> Dict :
301298 """
302- Dump serialized data with custom massaging to fix floats and remove specific keys as needed.
299+ Recursively sort the dictionary keys so that JavaScript won't change the order
300+ and change the generated checksum.
301+
302+ Params:
303+ data: Dictionary to sort.
303304 """
304305
305- dict_data = orjson .loads (serialized_data )
306- _fix_floats (dict_data )
307- _exclude_field_attributes (dict_data , exclude_field_attributes )
306+ if type (data ) is not dict :
307+ return data
308+
309+ items = [
310+ [k , v ]
311+ for k , v in sorted (
312+ data .items (),
313+ key = lambda item : item [0 ] if not is_int (item [0 ]) else int (item [0 ]),
314+ )
315+ ]
316+
317+ for item in items :
318+ if isinstance (item [1 ], dict ):
319+ item [1 ] = _sort_dict (item [1 ])
308320
309- dumped_data = orjson .dumps (dict_data )
310- return dumped_data
321+ return dict (items )
311322
312323
313324def _exclude_field_attributes (
@@ -346,8 +357,46 @@ def _exclude_field_attributes(
346357 del dict_data [field_name ][field_attr ]
347358
348359
360+ @lru_cache (maxsize = 128 , typed = True )
361+ def _dumps (
362+ serialized_data : bytes ,
363+ fix_floats : bool = True ,
364+ exclude_field_attributes : Tuple [str ] = None ,
365+ sort_dict : bool = True ,
366+ ) -> Dict :
367+ """
368+ Dump serialized data with custom massaging.
369+
370+ Features:
371+ - fix floats
372+ - remove specific keys as needed
373+ - sort dictionary
374+ """
375+
376+ data = orjson .loads (serialized_data )
377+
378+ if fix_floats :
379+ _fix_floats (data )
380+
381+ if exclude_field_attributes :
382+ # Excluding field attributes needs to de-serialize and then serialize again to
383+ # handle complex objects
384+ _exclude_field_attributes (data , exclude_field_attributes )
385+
386+ if sort_dict :
387+ # Sort dictionary manually because stringified integers don't get sorted
388+ # correctly with `orjson.OPT_SORT_KEYS` and JavaScript will sort the keys
389+ # as if they are integers
390+ data = _sort_dict (data )
391+
392+ return data
393+
394+
349395def dumps (
350- data : Dict , fix_floats : bool = True , exclude_field_attributes : Tuple [str ] = None
396+ data : Dict ,
397+ fix_floats : bool = True ,
398+ exclude_field_attributes : Tuple [str ] = None ,
399+ sort_dict : bool = True ,
351400) -> str :
352401 """
353402 Converts the passed-in dictionary to a string representation.
@@ -360,6 +409,8 @@ def dumps(
360409 but will be faster without it.
361410 param exclude_field_attributes: Tuple of strings with field attributes to remove, i.e. "1.2"
362411 to remove the key `2` from `{"1": {"2": "3"}}`
412+ param sort_dict: Whether the `dict` should be sorted. Defaults to `True`, but
413+ will be faster without it.
363414
364415 Returns a `str` instead of `bytes` (which deviates from `orjson.dumps`), but seems more useful.
365416 """
@@ -368,17 +419,17 @@ def dumps(
368419 exclude_field_attributes
369420 ), "exclude_field_attributes type needs to be a sequence"
370421
422+ # Call `dumps` to make sure that complex objects are serialized correctly
371423 serialized_data = orjson .dumps (data , default = _json_serializer )
372424
373- if fix_floats :
374- # Handle excluded field attributes in `_dumps` to reduce the amount of serialization/deserialization needed
375- serialized_data = _dumps (
376- serialized_data , exclude_field_attributes = exclude_field_attributes
377- )
378- elif exclude_field_attributes :
379- dict_data = orjson .loads (serialized_data )
380- _exclude_field_attributes (dict_data , exclude_field_attributes )
381- serialized_data = orjson .dumps (dict_data )
425+ data = _dumps (
426+ serialized_data ,
427+ fix_floats = fix_floats ,
428+ exclude_field_attributes = exclude_field_attributes ,
429+ sort_dict = sort_dict ,
430+ )
431+
432+ serialized_data = orjson .dumps (data )
382433
383434 return serialized_data .decode ("utf-8" )
384435
0 commit comments