4747import importlib .util
4848import json
4949import math
50+ import re
5051import sys
5152import traceback
5253import uuid
5758PROTOCOL = 'tywrap/1'
5859PROTOCOL_VERSION = 1
5960CODEC_VERSION = 1
61+ MAX_SERIALIZE_DEPTH = 900
62+ MAX_SERIALIZE_NODES = 1_000_000
63+ _SERIALIZE_PATH_IDENTIFIER = re .compile (r'^[A-Za-z_$][\w$]*$' , re .ASCII )
6064
6165
6266class ProtocolError (Exception ):
@@ -813,34 +817,48 @@ def serialize_stdlib(obj):
813817 return None
814818
815819
816- def serialize (obj , * , force_json_markers , torch_allow_copy = False ):
817- """
818- Top-level result serializer.
820+ _NO_SCIENTIFIC = object ()
819821
820- Scientific codecs are type-first and only inspect packages that the value can
821- belong to. A value from an optional package implies that package is already in
822- sys.modules, so these checks never cold-import the scientific stack. The
823- package dispatch deliberately precedes the JSON-native fast path: e.g. a
824- package-defined subclass of dict still receives its relevant codec check.
825- The remaining BridgeCodec value behaviors (numpy/pandas scalars, bytes, sets,
826- complex rejection, NaN/Infinity) are applied later during JSON encoding by
827- default_encoder.
828- """
822+
823+ def _check_serialize_depth (depth , path ):
824+ if depth > MAX_SERIALIZE_DEPTH :
825+ raise RuntimeError (
826+ f'Scientific envelope serialization maximum depth '
827+ f'{ MAX_SERIALIZE_DEPTH } exceeded at { path } '
828+ )
829+
830+
831+ def _check_serialize_nodes (nodes , path ):
832+ if nodes > MAX_SERIALIZE_NODES :
833+ raise RuntimeError (
834+ f'Scientific envelope serialization maximum visited nodes '
835+ f'{ MAX_SERIALIZE_NODES } exceeded at { path } '
836+ )
837+
838+
839+ def _serialize_scientific (obj , * , force_json_markers , torch_allow_copy , depth , path ):
840+ """Serialize a supported scientific value, or return _NO_SCIENTIFIC."""
829841 package = type (obj ).__module__ .split ('.' , 1 )[0 ]
830842
831843 if package == 'numpy' and 'numpy' in sys .modules :
832844 if is_numpy_array (obj ):
845+ _check_serialize_depth (depth , path )
833846 return serialize_ndarray (obj , force_json_markers = force_json_markers )
834847 elif package == 'pandas' and 'pandas' in sys .modules :
835848 if is_pandas_dataframe (obj ):
849+ _check_serialize_depth (depth , path )
836850 return serialize_dataframe (obj , force_json_markers = force_json_markers )
837851 if is_pandas_series (obj ):
852+ _check_serialize_depth (depth , path )
838853 return serialize_series (obj , force_json_markers = force_json_markers )
839854 elif package == 'scipy' and 'scipy.sparse' in sys .modules :
840855 if is_scipy_sparse (obj ):
856+ _check_serialize_depth (depth , path )
841857 return serialize_sparse_matrix (obj )
842858 elif package == 'torch' and 'torch' in sys .modules :
843859 if is_torch_tensor (obj ):
860+ _check_serialize_depth (depth , path )
861+ _check_serialize_depth (depth + 1 , _serialize_path (path , 'value' ))
844862 return serialize_torch_tensor (
845863 obj , force_json_markers = force_json_markers , torch_allow_copy = torch_allow_copy
846864 )
@@ -849,18 +867,168 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
849867 # BaseEstimator is sklearn's documented extension point, so user-defined
850868 # estimators live outside the 'sklearn' package and must still get the
851869 # estimator serializer (and its param-naming errors).
870+ _check_serialize_depth (depth , path )
852871 return serialize_sklearn_estimator (obj )
853872
854- if isinstance (obj , (type (None ), bool , int , float , str , dict , list , tuple )):
855- return obj
873+ return _NO_SCIENTIFIC
874+
875+
876+ def _serialize_path (base , key ):
877+ """Build a decoder-compatible JSONPath-like result path."""
878+ if isinstance (key , int ):
879+ return f'{ base } [{ key } ]'
880+ if _SERIALIZE_PATH_IDENTIFIER .fullmatch (key ):
881+ return f'{ base } .{ key } '
882+ return f'{ base } [{ json .dumps (key , ensure_ascii = False )} ]'
856883
857- pydantic_value = serialize_pydantic (obj )
884+
885+ def _invalid_key_path (base , key ):
886+ """Name a dict key that cannot be represented by JSON."""
887+ return f'{ base } [{ key !r} ]'
888+
889+
890+ def _needs_serialize_visit (value ):
891+ """Return whether value needs container or scientific traversal work."""
892+ if type (value ) in (type (None ), bool , int , float , str ):
893+ return False
894+ if type (value ) in (dict , list , tuple ):
895+ return True
896+ package = type (value ).__module__ .split ('.' , 1 )[0 ]
897+ if package in ('numpy' , 'pandas' , 'scipy' , 'torch' ):
898+ return True
899+ return 'sklearn.base' in sys .modules and is_sklearn_estimator (value )
900+
901+
902+ def _serialize_leaf (value ):
903+ """Apply non-container conversions without allocating a traversal frame."""
904+ if type (value ) in (type (None ), bool , int , float , str ):
905+ return value
906+ pydantic_value = serialize_pydantic (value )
858907 if pydantic_value is not _NO_PYDANTIC :
859908 return pydantic_value
860- stdlib_value = serialize_stdlib (obj )
909+ stdlib_value = serialize_stdlib (value )
861910 if stdlib_value is not None :
862911 return stdlib_value
863- return obj
912+ return value
913+
914+
915+ def serialize (obj , * , force_json_markers , torch_allow_copy = False ):
916+ """
917+ Top-level result serializer.
918+
919+ Scientific codecs are type-first and only inspect packages that the value can
920+ belong to. A value from an optional package implies that package is already in
921+ sys.modules, so these checks never cold-import the scientific stack. The
922+ package dispatch deliberately precedes the JSON-native fast path: e.g. a
923+ package-defined subclass of dict still receives its relevant codec check.
924+ Every other value is left untouched so the shared JSON encoder applies the
925+ exact same default conversion at the root and at any nested depth.
926+ """
927+ root = [None ]
928+ active_ids = set ()
929+ stack = [('visit' , obj , 0 , 'result' , root , 0 )]
930+ visited_nodes = 0
931+
932+ # Repeated aliases have value semantics and are intentionally serialized twice.
933+ while stack :
934+ frame = stack .pop ()
935+ action = frame [0 ]
936+ if action == 'dict' :
937+ _ , current , depth , path , parent , key , output , iterator = frame
938+ try :
939+ item_key , item = next (iterator )
940+ except StopIteration :
941+ active_ids .remove (id (current ))
942+ parent [key ] = output
943+ continue
944+ stack .append (frame )
945+ if not (isinstance (item_key , (str , int , float , bool )) or item_key is None ):
946+ invalid_path = _invalid_key_path (path , item_key )
947+ raise TypeError (
948+ f'keys must be str, int, float, bool or None, not '
949+ f'{ type (item_key ).__name__ } at { invalid_path } '
950+ )
951+ child_key = next (iter (json .loads (json .dumps ({item_key : None }))))
952+ child_path = _serialize_path (path , child_key )
953+ if _needs_serialize_visit (item ):
954+ stack .append (('visit' , item , depth + 1 , child_path , output , item_key ))
955+ else :
956+ output [item_key ] = _serialize_leaf (item )
957+ continue
958+ if action == 'sequence' :
959+ _ , current , depth , path , parent , key , output , index = frame
960+ if index == len (output ):
961+ active_ids .remove (id (current ))
962+ parent [key ] = output if type (current ) is list else tuple (output )
963+ continue
964+ stack .append (('sequence' , current , depth , path , parent , key , output , index + 1 ))
965+ item = current [index ]
966+ if _needs_serialize_visit (item ):
967+ stack .append (
968+ ('visit' , item , depth + 1 , _serialize_path (path , index ), output , index )
969+ )
970+ else :
971+ output [index ] = _serialize_leaf (item )
972+ continue
973+
974+ _ , current , depth , path , parent , key = frame
975+ try :
976+ scientific = _serialize_scientific (
977+ current ,
978+ force_json_markers = force_json_markers ,
979+ torch_allow_copy = torch_allow_copy ,
980+ depth = depth ,
981+ path = path ,
982+ )
983+ except Exception as exc :
984+ if path == 'result' :
985+ raise
986+ raise RuntimeError (f'Scientific value serialization failed at { path } : { exc } ' ) from exc
987+ if scientific is not _NO_SCIENTIFIC :
988+ # Recognized envelopes are terminal containers to the JS decoder.
989+ visited_nodes += 1
990+ _check_serialize_nodes (visited_nodes , path )
991+ if scientific .get ('__tywrap__' ) == 'torch.tensor' :
992+ nested_path = _serialize_path (path , 'value' )
993+ try :
994+ visited_nodes += 1
995+ _check_serialize_nodes (visited_nodes , nested_path )
996+ except Exception as exc :
997+ if path == 'result' :
998+ raise
999+ raise RuntimeError (
1000+ f'Scientific value serialization failed at { path } : { exc } '
1001+ ) from exc
1002+ parent [key ] = scientific
1003+ continue
1004+
1005+ container_type = type (current )
1006+ if container_type in (dict , list , tuple ):
1007+ _check_serialize_depth (depth , path )
1008+ visited_nodes += 1
1009+ _check_serialize_nodes (visited_nodes , path )
1010+ current_id = id (current )
1011+ if current_id in active_ids :
1012+ raise RuntimeError (f'Circular reference detected at { path } ' )
1013+ active_ids .add (current_id )
1014+
1015+ if container_type is dict :
1016+ output = {}
1017+ parent [key ] = output
1018+ stack .append (
1019+ ('dict' , current , depth , path , parent , key , output , iter (current .items ()))
1020+ )
1021+ continue
1022+
1023+ output = [None ] * len (current )
1024+ if container_type is list :
1025+ parent [key ] = output
1026+ stack .append (('sequence' , current , depth , path , parent , key , output , 0 ))
1027+ continue
1028+
1029+ parent [key ] = _serialize_leaf (current )
1030+
1031+ return root [0 ]
8641032
8651033
8661034# =============================================================================
@@ -982,12 +1150,10 @@ def encode_value(value, *, allow_nan):
9821150 # ("...not JSON compliant: nan"), but 3.10/3.11 emit only the canonical
9831151 # "Out of range float values are not JSON compliant". Match that phrase too
9841152 # so the typed error message is stable across versions.
1153+ nonfinite_token = re .search (r'(^|[^a-z])(nan|inf|infinity)([^a-z]|$)' , error_msg )
9851154 if (
986- 'nan' in error_msg
987- or 'infinity' in error_msg
988- or 'inf' in error_msg
989- or 'out of range float' in error_msg
990- ):
1155+ not allow_nan and 'out of range float values are not json compliant' in error_msg
1156+ ) or nonfinite_token :
9911157 raise CodecError ('Cannot serialize NaN - NaN/Infinity not allowed in JSON' ) from exc
9921158 raise CodecError (f'JSON encoding failed: { exc } ' ) from exc
9931159 except TypeError as exc :
0 commit comments