1414from __future__ import annotations
1515
1616import dataclasses
17- import inspect
1817import json
1918import types
2019import typing
@@ -48,8 +47,7 @@ def to_json(obj: Any) -> str:
4847 ) from e
4948
5049
51- def from_json (json_str : str | bytes | bytearray , expected_type : type | None = None ,
52- converter : Any | None = None ) -> Any :
50+ def from_json (json_str : str | bytes | bytearray , expected_type : type | None = None ) -> Any :
5351 """Deserialize a JSON string, optionally coercing the result to a type.
5452
5553 When ``expected_type`` is ``None`` (the default) the raw parsed JSON is
@@ -64,15 +62,11 @@ def from_json(json_str: str | bytes | bytearray, expected_type: type | None = No
6462 classmethod are reconstructed via that hook, and ``Optional``/``Union`` and
6563 ``list`` type hints are honored recursively. The destination type is always
6664 supplied by the caller; it is never read from the payload.
67-
68- ``converter`` is the active :class:`~durabletask.serialization.DataConverter`.
69- It is forwarded to ``from_json`` hooks that opt in (see :func:`coerce_to_type`)
70- so they can reconstruct nested typed values via ``converter.coerce(...)``.
7165 """
7266 if expected_type is None :
7367 return json .loads (json_str , object_hook = _legacy_object_hook )
7468 raw = json .loads (json_str , object_hook = _strip_legacy_marker )
75- return coerce_to_type (raw , expected_type , converter )
69+ return coerce_to_type (raw , expected_type )
7670
7771
7872def _encode_custom_object (o : Any ) -> Any :
@@ -82,31 +76,18 @@ def _encode_custom_object(o: Any) -> Any:
8276 namedtuples are handled natively by the encoder (serialized as JSON arrays)
8377 and never reach this hook.
8478 """
79+ if dataclasses .is_dataclass (o ) and not isinstance (o , type ):
80+ return dataclasses .asdict (o )
81+ if isinstance (o , SimpleNamespace ):
82+ return vars (o )
8583 # Custom objects may opt in via a ``to_json`` hook. It is resolved off the
8684 # type and called with the instance (``type(o).to_json(o)``) so that both
8785 # instance methods and ``@staticmethod`` hooks work -- matching the calling
8886 # convention used by ``azure-functions-durable``. The hook returns a
8987 # JSON-serializable value (a structure or a string), not a JSON document.
90- #
91- # The hook is checked before the dataclass / ``SimpleNamespace`` branches so
92- # a type may override the default structural encoding -- mirroring the read
93- # path, where :func:`coerce_to_type` consults ``from_json`` before its
94- # dataclass branch. This matters for dataclasses whose fields are not
95- # JSON-native (e.g. ``timedelta`` / ``datetime``), which ``asdict`` alone
96- # cannot serialize.
9788 to_json_hook = getattr (cast (Any , type (o )), "to_json" , None )
9889 if callable (to_json_hook ):
9990 return to_json_hook (o )
100- if dataclasses .is_dataclass (o ) and not isinstance (o , type ):
101- # Shallow-convert to a dict whose *values are the original field objects*
102- # (unlike ``dataclasses.asdict``, which deep-recurses and would convert a
103- # nested dataclass via ``asdict`` -- bypassing that child's ``to_json``
104- # hook). ``json.dumps`` then recurses into each value and re-enters this
105- # hook for any nested custom object, so nested ``to_json`` hooks fire at
106- # every depth (including inside lists/dicts).
107- return {f .name : getattr (o , f .name ) for f in dataclasses .fields (o )}
108- if isinstance (o , SimpleNamespace ):
109- return vars (o )
11091 # This will raise a TypeError describing the unsupported type.
11192 raise TypeError (f"Object of type '{ type (o ).__name__ } ' is not JSON serializable" )
11293
@@ -124,31 +105,20 @@ def _strip_legacy_marker(d: dict[str, Any]) -> dict[str, Any]:
124105 return d
125106
126107
127- def coerce_to_type (value : Any , expected_type : Any , converter : Any | None = None ) -> Any :
108+ def coerce_to_type (value : Any , expected_type : Any ) -> Any :
128109 """Coerce an already-parsed JSON value to ``expected_type``.
129110
130111 Handles ``None``/``Optional``/``Union`` and ``list`` type hints recursively,
131112 types exposing a ``from_json()`` classmethod, and dataclasses (including
132113 nested dataclass fields). The destination type is always caller-supplied and
133114 never derived from the payload, keeping deserialization secure.
134-
135- ``converter`` is the active :class:`~durabletask.serialization.DataConverter`.
136- A ``from_json`` hook may opt in to receiving it (by accepting a second
137- positional parameter) and delegate nested reconstruction back to the
138- converter, e.g. ``converter.coerce(child, ChildType)``. This keeps hooks free
139- of manual nested deserialization and routes children through the same policy.
140115 """
141116 if expected_type is None or value is None :
142117 return value
143118
144- # ``Any`` imposes no constraint -- and ``isinstance(x, Any)`` raises -- so
145- # short-circuit before any type inspection below.
146- if expected_type is typing .Any :
147- return value
148-
149119 origin = typing .get_origin (expected_type )
150120 if origin is not None :
151- return _coerce_generic (value , expected_type , origin , converter )
121+ return _coerce_generic (value , expected_type , origin )
152122
153123 if not isinstance (expected_type , type ):
154124 # Not a concrete, instantiable type (e.g. a typing special form we don't
@@ -160,10 +130,10 @@ def coerce_to_type(value: Any, expected_type: Any, converter: Any | None = None)
160130
161131 from_json_hook = getattr (expected_type , "from_json" , None )
162132 if callable (from_json_hook ):
163- return _invoke_from_json ( from_json_hook , value , converter )
133+ return from_json_hook ( value )
164134
165135 if dataclasses .is_dataclass (expected_type ) and isinstance (value , dict ):
166- return _build_dataclass (expected_type , cast (dict [str , Any ], value ), converter )
136+ return _build_dataclass (expected_type , cast (dict [str , Any ], value ))
167137
168138 type_ctor = cast (Any , expected_type )
169139 try :
@@ -176,69 +146,29 @@ def coerce_to_type(value: Any, expected_type: Any, converter: Any | None = None)
176146 ) from e
177147
178148
179- def _invoke_from_json (from_json_hook : Any , value : Any , converter : Any | None ) -> Any :
180- """Invoke a ``from_json`` hook, passing the converter if the hook accepts it.
181-
182- Hooks may be declared as ``from_json(cls, value)`` (the original contract) or
183- ``from_json(cls, value, converter)`` to opt into managed nested
184- reconstruction. Arity is detected from the bound hook's signature, so the
185- extra parameter is fully backwards compatible. When a converter-aware hook is
186- found but no converter was threaded (e.g. a direct ``json_codec`` call), the
187- shared default converter is resolved lazily so the hook always receives one.
188- """
189- wants_converter = False
190- try :
191- params = [
192- p for p in inspect .signature (from_json_hook ).parameters .values ()
193- if p .kind in (inspect .Parameter .POSITIONAL_ONLY ,
194- inspect .Parameter .POSITIONAL_OR_KEYWORD )
195- ]
196- wants_converter = len (params ) >= 2
197- except (TypeError , ValueError ):
198- wants_converter = False
199-
200- if wants_converter :
201- if converter is None :
202- converter = _default_converter ()
203- return from_json_hook (value , converter )
204- return from_json_hook (value )
205-
206-
207- def _default_converter () -> Any :
208- # Lazy import to avoid a load-time cycle: ``serialization`` imports this
209- # module at import time, but by the time a hook actually runs both modules
210- # are fully initialized.
211- from durabletask .serialization import DEFAULT_DATA_CONVERTER
212- return DEFAULT_DATA_CONVERTER
213-
214-
215- def _coerce_generic (value : Any , expected_type : Any , origin : Any , converter : Any | None = None ) -> Any :
149+ def _coerce_generic (value : Any , expected_type : Any , origin : Any ) -> Any :
216150 args = typing .get_args (expected_type )
217151 if origin is typing .Union or origin is types .UnionType :
218152 # If the value already matches a member type, keep it as-is.
219153 non_none = [a for a in args if a is not type (None )]
220154 for arg in non_none :
221- # An ``Any`` member imposes no constraint (and ``isinstance(x, Any)``
222- # raises), so the value is acceptable as-is.
223- if arg is typing .Any :
224- return value
225155 if isinstance (arg , type ) and isinstance (value , arg ):
226156 return value
227157 # ``Optional[T]`` (exactly one non-None member): coerce to that member.
228158 # For a genuine multi-member ``Union`` where the value matched none of
229159 # the members, leave it untouched rather than guessing the first arg --
230160 # forcing a coercion there can silently mis-construct the wrong type.
231161 if len (non_none ) == 1 :
232- return coerce_to_type (value , non_none [0 ], converter )
162+ return coerce_to_type (value , non_none [0 ])
233163 return value
234164 if origin in (list , Sequence ) and isinstance (value , list ):
235165 elem_type = args [0 ] if args else None
236- return [coerce_to_type (item , elem_type , converter ) for item in cast (list [Any ], value )]
166+ return [coerce_to_type (item , elem_type ) for item in cast (list [Any ], value )]
237167 # Other generics (dict, tuple, ...) are returned as parsed JSON.
238168 return value
239169
240170
241- def _build_dataclass (cls : Any , data : dict [str , Any ], converter : Any | None = None ) -> Any :
171+ def _build_dataclass (cls : Any , data : dict [str , Any ]) -> Any :
242172 """Construct a dataclass from its dict payload, recursing into typed fields."""
243173 try :
244174 hints = typing .get_type_hints (cls )
@@ -249,5 +179,5 @@ def _build_dataclass(cls: Any, data: dict[str, Any], converter: Any | None = Non
249179 if field .name not in data :
250180 continue
251181 field_type = hints .get (field .name )
252- kwargs [field .name ] = coerce_to_type (data [field .name ], field_type , converter )
182+ kwargs [field .name ] = coerce_to_type (data [field .name ], field_type )
253183 return cls (** kwargs )
0 commit comments