4242import types
4343import typing
4444from abc import ABC , abstractmethod
45- from collections .abc import Mapping , Sequence
45+ from collections .abc import Iterable , Mapping , Sequence
46+ from collections .abc import Set as AbstractSet
4647from types import SimpleNamespace
4748from typing import Any , cast
4849
@@ -68,7 +69,6 @@ class DataConverter(ABC):
6869 @abstractmethod
6970 def serialize (self , value : Any ) -> str | None :
7071 """Serialize ``value`` to a string, or ``None`` when ``value`` is ``None``."""
71- pass
7272
7373 @abstractmethod
7474 def deserialize (self , data : str | None , target_type : type | None = None ) -> Any :
@@ -85,7 +85,6 @@ def deserialize(self, data: str | None, target_type: type | None = None) -> Any:
8585 :class:`JsonDataConverter` is best-effort and falls back; a validating
8686 converter may instead raise.
8787 """
88- pass
8988
9089 @abstractmethod
9190 def coerce (self , value : Any , target_type : type | None = None ) -> Any :
@@ -99,7 +98,6 @@ def coerce(self, value: Any, target_type: type | None = None) -> Any:
9998 (strict vs. best-effort) that an implementation applies in
10099 :meth:`deserialize` should apply here.
101100 """
102- pass
103101
104102 def can_reconstruct (self , target_type : Any ) -> bool :
105103 """Return True if this converter can rebuild ``target_type`` from a payload.
@@ -136,7 +134,9 @@ class JsonDataConverter(DataConverter):
136134 ``SimpleNamespace`` handling, and nested values are encoded recursively, so
137135 a dataclass (or any object) with a ``to_json`` hook -- including one nested
138136 inside another dataclass, ``list``, or ``dict`` -- round-trips through its
139- own hooks.
137+ own hooks. Sets and frozensets serialize to JSON arrays ordered
138+ deterministically by their canonical JSON representation (using lexical
139+ string order, so numeric sets like ``{2, 10}`` serialize as ``[10, 2]``).
140140
141141 Deserialization (and value-level :meth:`coerce`) is **best-effort**: when a
142142 ``target_type`` is supplied and the value cannot be coerced to it, the raw
@@ -167,6 +167,18 @@ class JsonDataConverter(DataConverter):
167167 > payload; the destination type is always supplied by the caller.
168168 """
169169
170+ def __init__ (
171+ self ,
172+ * ,
173+ enable_generic_sets : bool = False ,
174+ enable_set_coercion : bool = False ,
175+ ) -> None :
176+ self .enable_generic_sets : bool = enable_generic_sets or enable_set_coercion
177+
178+ @property
179+ def enable_set_coercion (self ) -> bool :
180+ return self .enable_generic_sets
181+
170182 def serialize (self , value : Any ) -> str | None :
171183 if value is None :
172184 return None
@@ -221,15 +233,40 @@ def _log_coercion_fallback(target_type: type, error: Exception) -> None:
221233# ---------------------------------------------------------------------------
222234
223235
236+ def _is_supported_set_element (converter : DataConverter , elem_type : Any ) -> bool :
237+ if elem_type is None or elem_type is typing .Any :
238+ return True
239+ elem_origin = typing .get_origin (elem_type )
240+ if elem_origin is not None :
241+ if elem_origin in (list , dict , Sequence , Mapping , set , AbstractSet ):
242+ # Mutable collections are unhashable
243+ return False
244+ if elem_origin is typing .Union or elem_origin is types .UnionType :
245+ non_none = [a for a in typing .get_args (elem_type ) if a is not type (None )]
246+ return bool (non_none ) and all (_is_supported_set_element (converter , a ) for a in non_none )
247+ if elem_origin is frozenset :
248+ args = typing .get_args (elem_type )
249+ return not args or _is_supported_set_element (converter , args [0 ])
250+ return False
251+ if not isinstance (elem_type , type ):
252+ return False
253+ if issubclass (elem_type , (list , dict , set )):
254+ return False
255+ return True
256+
257+
224258def _can_reconstruct (converter : DataConverter , target_type : Any ) -> bool :
225259 """:class:`JsonDataConverter`'s reconstruction policy.
226260
227261 Recognizes dataclasses and ``from_json()``-capable types, plus ``Optional``
228262 / ``list`` / ``Sequence`` hints wrapping them; builtins and unknown
229- annotations are excluded. Recurses through ``converter.can_reconstruct``
230- (not itself) so a :class:`JsonDataConverter` subclass that overrides
231- ``can_reconstruct`` still participates in the element-type checks of
232- ``Optional`` / ``list`` hints.
263+ annotations are excluded. When set support is enabled on the converter
264+ (via ``enable_generic_sets=True``), ``set`` / ``frozenset`` / ``Set``
265+ containers with supported element types are also recognized. Recurses
266+ through ``converter.can_reconstruct`` (not itself) so a
267+ :class:`JsonDataConverter` subclass that overrides ``can_reconstruct``
268+ still participates in the element-type checks of ``Optional`` / ``list``
269+ hints.
233270 """
234271 origin = typing .get_origin (target_type )
235272 if origin is not None :
@@ -240,11 +277,19 @@ def _can_reconstruct(converter: DataConverter, target_type: Any) -> bool:
240277 )
241278 if origin in (list , Sequence ):
242279 return any (converter .can_reconstruct (a ) for a in args )
280+ if origin in (set , frozenset , AbstractSet , typing .AbstractSet ):
281+ if not getattr (converter , "enable_generic_sets" , False ):
282+ return False
283+ if not args :
284+ return True
285+ return _is_supported_set_element (converter , args [0 ])
243286 return False
244287 if not isinstance (target_type , type ):
245288 return False
246289 if dataclasses .is_dataclass (target_type ):
247290 return True
291+ if target_type in (set , frozenset , AbstractSet ):
292+ return getattr (converter , "enable_generic_sets" , False )
248293 return callable (getattr (cast (Any , target_type ), "from_json" , None ))
249294
250295
@@ -253,9 +298,12 @@ def _to_json(obj: Any) -> str:
253298
254299 Builtins serialize to plain JSON. Objects exposing a ``to_json()`` method,
255300 dataclasses, and ``SimpleNamespace`` instances are serialized to plain JSON
256- as well (without any type marker), recursively. Custom objects can be
257- reconstructed on the receiving side by passing ``expected_type`` to
258- :func:`_from_json`.
301+ as well (without any type marker), recursively. Sets and frozensets are
302+ serialized as JSON arrays with elements deterministically ordered by their
303+ canonical JSON string representation (note that numeric set elements follow
304+ lexical JSON ordering, e.g. ``{2, 10}`` serializes as ``[10, 2]``). Custom
305+ objects can be reconstructed on the receiving side by passing ``expected_type``
306+ to :func:`_from_json`.
259307 """
260308 try :
261309 return json .dumps (obj , default = _encode_custom_object )
@@ -319,6 +367,31 @@ def _encode_custom_object(o: Any) -> Any:
319367 to_json_hook = getattr (cast (Any , type (o )), "to_json" , None )
320368 if callable (to_json_hook ):
321369 return to_json_hook (o )
370+ if isinstance (o , (set , frozenset , AbstractSet )):
371+ items : AbstractSet [Any ] = cast ("AbstractSet[Any]" , o )
372+ # Sets and frozensets are unordered collections. To ensure deterministic
373+ # serialization across processes, worker replays, and PYTHONHASHSEED
374+ # variations, elements are individually encoded to canonical JSON
375+ # (with sort_keys=True), sorted lexically by their encoded JSON string,
376+ # and emitted as normalized parsed values.
377+ #
378+ # Emitting the normalized parsed values rather than original objects
379+ # guarantees identical order and eliminates leaks from dict insertion
380+ # order variations, NaN/non-finite comparison anomalies, or partial
381+ # ordering in frozensets. A consequence of sorting canonical JSON strings
382+ # is lexical ordering for numeric sets (e.g., {2, 10} serializes as
383+ # [10, 2]).
384+ try :
385+ encoded_items = sorted (
386+ json .dumps (item , default = _encode_custom_object , sort_keys = True )
387+ for item in items
388+ )
389+ return [json .loads (encoded ) for encoded in encoded_items ]
390+ except Exception as e :
391+ raise TypeError (
392+ f"Cannot deterministically serialize set of type '{ type (items ).__name__ } ' "
393+ f"because its elements cannot be compared or deterministically encoded: { e } "
394+ ) from e
322395 if isinstance (o , enum .Enum ):
323396 # Emit the member's underlying value (a primitive). Reconstruction is
324397 # type-directed: passing the enum type to ``deserialize`` rebuilds the
@@ -477,6 +550,19 @@ def _coerce_generic(value: Any, expected_type: Any, origin: Any,
477550 if origin in (list , Sequence ) and isinstance (value , list ):
478551 elem_type = args [0 ] if args else None
479552 return [_coerce_to_type (item , elem_type , converter ) for item in cast (list [Any ], value )]
553+ if origin in (set , frozenset , AbstractSet , typing .AbstractSet ) and isinstance (
554+ value , (list , set , frozenset , AbstractSet )
555+ ):
556+ if not getattr (converter , "enable_generic_sets" , False ):
557+ return cast (Any , value )
558+ elem_type = args [0 ] if args else None
559+ coerced : list [Any ] = [
560+ _coerce_to_type (item , elem_type , converter )
561+ for item in cast ("Iterable[Any]" , value )
562+ ]
563+ if origin is frozenset :
564+ return frozenset (coerced )
565+ return set (coerced )
480566 if origin in (dict , Mapping ) and isinstance (value , dict ):
481567 # JSON object keys are always strings, so only the values can carry a
482568 # reconstructable type. Keys are passed through unchanged.
@@ -595,12 +681,12 @@ def _resolve_forward_refs(tp: Any, globalns: dict[str, Any]) -> Any:
595681 """
596682 if isinstance (tp , str ):
597683 try :
598- tp = eval (tp , globalns ) # noqa: S307 - resolves caller-authored annotations
684+ tp = eval (tp , globalns )
599685 except Exception :
600686 return tp
601687 elif isinstance (tp , typing .ForwardRef ):
602688 try :
603- tp = eval (tp .__forward_arg__ , globalns ) # noqa: S307
689+ tp = eval (tp .__forward_arg__ , globalns )
604690 except Exception :
605691 return tp
606692
0 commit comments