Skip to content

Commit 58ee9c9

Browse files
committed
Let to_json hook take precedence over dataclass asdict in serializer
The JSON serializer encoded dataclasses via dataclasses.asdict before ever checking for a to_json hook, so a dataclass could not override its own serialization -- a problem for dataclasses whose fields are not JSON-native (e.g. timedelta/datetime). The read path already consults from_json before its dataclass branch; this makes the write path symmetric by checking the to_json hook first and falling back to asdict/SimpleNamespace.
1 parent 8298f71 commit 58ee9c9

2 files changed

Lines changed: 45 additions & 4 deletions

File tree

durabletask/internal/json_codec.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -76,18 +76,25 @@ def _encode_custom_object(o: Any) -> Any:
7676
namedtuples are handled natively by the encoder (serialized as JSON arrays)
7777
and never reach this hook.
7878
"""
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)
8379
# Custom objects may opt in via a ``to_json`` hook. It is resolved off the
8480
# type and called with the instance (``type(o).to_json(o)``) so that both
8581
# instance methods and ``@staticmethod`` hooks work -- matching the calling
8682
# convention used by ``azure-functions-durable``. The hook returns a
8783
# JSON-serializable value (a structure or a string), not a JSON document.
84+
#
85+
# The hook is checked before the dataclass / ``SimpleNamespace`` branches so
86+
# a type may override the default structural encoding -- mirroring the read
87+
# path, where :func:`coerce_to_type` consults ``from_json`` before its
88+
# dataclass branch. This matters for dataclasses whose fields are not
89+
# JSON-native (e.g. ``timedelta`` / ``datetime``), which ``asdict`` alone
90+
# cannot serialize.
8891
to_json_hook = getattr(cast(Any, type(o)), "to_json", None)
8992
if callable(to_json_hook):
9093
return to_json_hook(o)
94+
if dataclasses.is_dataclass(o) and not isinstance(o, type):
95+
return dataclasses.asdict(o)
96+
if isinstance(o, SimpleNamespace):
97+
return vars(o)
9198
# This will raise a TypeError describing the unsupported type.
9299
raise TypeError(f"Object of type '{type(o).__name__}' is not JSON serializable")
93100

tests/durabletask/test_serialization.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,40 @@ def test_instance_to_json_hook_receives_instance():
9393
assert json.loads(json_codec.to_json(Widget("gear", 5))) == {"label": "gear", "size": 5}
9494

9595

96+
@dataclass
97+
class HookedDataclass:
98+
"""Dataclass that overrides serialization via to_json/from_json.
99+
100+
Its ``value`` field is not JSON-native on the wire (it is encoded as a
101+
string), so plain ``dataclasses.asdict`` would not round-trip. The hooks
102+
take precedence over the default dataclass encoding.
103+
"""
104+
105+
value: int
106+
107+
def to_json(self) -> dict:
108+
return {"value": str(self.value)}
109+
110+
@classmethod
111+
def from_json(cls, data: dict) -> "HookedDataclass":
112+
return cls(int(data["value"]))
113+
114+
def __eq__(self, other: object) -> bool:
115+
return isinstance(other, HookedDataclass) and other.value == self.value
116+
117+
118+
def test_dataclass_to_json_hook_takes_precedence_over_asdict():
119+
# A dataclass exposing to_json should serialize via the hook, not asdict.
120+
encoded = json_codec.to_json(HookedDataclass(7))
121+
assert json.loads(encoded) == {"value": "7"}
122+
123+
124+
def test_dataclass_hook_round_trips_with_expected_type():
125+
encoded = json_codec.to_json(HookedDataclass(7))
126+
result = json_codec.from_json(encoded, HookedDataclass)
127+
assert result == HookedDataclass(7)
128+
129+
96130
# ----- to_json -----
97131

98132

0 commit comments

Comments
 (0)