1414from __future__ import annotations
1515
1616import dataclasses
17+ import inspect
1718import json
1819import types
1920import typing
@@ -47,7 +48,8 @@ def to_json(obj: Any) -> str:
4748 ) from e
4849
4950
50- def from_json (json_str : str | bytes | bytearray , expected_type : type | None = None ) -> Any :
51+ def from_json (json_str : str | bytes | bytearray , expected_type : type | None = None ,
52+ converter : Any | None = None ) -> Any :
5153 """Deserialize a JSON string, optionally coercing the result to a type.
5254
5355 When ``expected_type`` is ``None`` (the default) the raw parsed JSON is
@@ -62,11 +64,15 @@ def from_json(json_str: str | bytes | bytearray, expected_type: type | None = No
6264 classmethod are reconstructed via that hook, and ``Optional``/``Union`` and
6365 ``list`` type hints are honored recursively. The destination type is always
6466 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(...)``.
6571 """
6672 if expected_type is None :
6773 return json .loads (json_str , object_hook = _legacy_object_hook )
6874 raw = json .loads (json_str , object_hook = _strip_legacy_marker )
69- return coerce_to_type (raw , expected_type )
75+ return coerce_to_type (raw , expected_type , converter )
7076
7177
7278def _encode_custom_object (o : Any ) -> Any :
@@ -92,7 +98,13 @@ def _encode_custom_object(o: Any) -> Any:
9298 if callable (to_json_hook ):
9399 return to_json_hook (o )
94100 if dataclasses .is_dataclass (o ) and not isinstance (o , type ):
95- return dataclasses .asdict (o )
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 )}
96108 if isinstance (o , SimpleNamespace ):
97109 return vars (o )
98110 # This will raise a TypeError describing the unsupported type.
@@ -112,20 +124,31 @@ def _strip_legacy_marker(d: dict[str, Any]) -> dict[str, Any]:
112124 return d
113125
114126
115- def coerce_to_type (value : Any , expected_type : Any ) -> Any :
127+ def coerce_to_type (value : Any , expected_type : Any , converter : Any | None = None ) -> Any :
116128 """Coerce an already-parsed JSON value to ``expected_type``.
117129
118130 Handles ``None``/``Optional``/``Union`` and ``list`` type hints recursively,
119131 types exposing a ``from_json()`` classmethod, and dataclasses (including
120132 nested dataclass fields). The destination type is always caller-supplied and
121133 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.
122140 """
123141 if expected_type is None or value is None :
124142 return value
125143
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+
126149 origin = typing .get_origin (expected_type )
127150 if origin is not None :
128- return _coerce_generic (value , expected_type , origin )
151+ return _coerce_generic (value , expected_type , origin , converter )
129152
130153 if not isinstance (expected_type , type ):
131154 # Not a concrete, instantiable type (e.g. a typing special form we don't
@@ -137,10 +160,10 @@ def coerce_to_type(value: Any, expected_type: Any) -> Any:
137160
138161 from_json_hook = getattr (expected_type , "from_json" , None )
139162 if callable (from_json_hook ):
140- return from_json_hook ( value )
163+ return _invoke_from_json ( from_json_hook , value , converter )
141164
142165 if dataclasses .is_dataclass (expected_type ) and isinstance (value , dict ):
143- return _build_dataclass (expected_type , cast (dict [str , Any ], value ))
166+ return _build_dataclass (expected_type , cast (dict [str , Any ], value ), converter )
144167
145168 type_ctor = cast (Any , expected_type )
146169 try :
@@ -153,29 +176,69 @@ def coerce_to_type(value: Any, expected_type: Any) -> Any:
153176 ) from e
154177
155178
156- def _coerce_generic (value : Any , expected_type : Any , origin : Any ) -> Any :
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 :
157216 args = typing .get_args (expected_type )
158217 if origin is typing .Union or origin is types .UnionType :
159218 # If the value already matches a member type, keep it as-is.
160219 non_none = [a for a in args if a is not type (None )]
161220 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
162225 if isinstance (arg , type ) and isinstance (value , arg ):
163226 return value
164227 # ``Optional[T]`` (exactly one non-None member): coerce to that member.
165228 # For a genuine multi-member ``Union`` where the value matched none of
166229 # the members, leave it untouched rather than guessing the first arg --
167230 # forcing a coercion there can silently mis-construct the wrong type.
168231 if len (non_none ) == 1 :
169- return coerce_to_type (value , non_none [0 ])
232+ return coerce_to_type (value , non_none [0 ], converter )
170233 return value
171234 if origin in (list , Sequence ) and isinstance (value , list ):
172235 elem_type = args [0 ] if args else None
173- return [coerce_to_type (item , elem_type ) for item in cast (list [Any ], value )]
236+ return [coerce_to_type (item , elem_type , converter ) for item in cast (list [Any ], value )]
174237 # Other generics (dict, tuple, ...) are returned as parsed JSON.
175238 return value
176239
177240
178- def _build_dataclass (cls : Any , data : dict [str , Any ]) -> Any :
241+ def _build_dataclass (cls : Any , data : dict [str , Any ], converter : Any | None = None ) -> Any :
179242 """Construct a dataclass from its dict payload, recursing into typed fields."""
180243 try :
181244 hints = typing .get_type_hints (cls )
@@ -186,5 +249,5 @@ def _build_dataclass(cls: Any, data: dict[str, Any]) -> Any:
186249 if field .name not in data :
187250 continue
188251 field_type = hints .get (field .name )
189- kwargs [field .name ] = coerce_to_type (data [field .name ], field_type )
252+ kwargs [field .name ] = coerce_to_type (data [field .name ], field_type , converter )
190253 return cls (** kwargs )
0 commit comments