33
44"""Tests for the DataConverter abstraction and the default JsonDataConverter."""
55
6+ import inspect
67import json
78import logging
8- from dataclasses import dataclass
9+ from dataclasses import dataclass , field
10+ from functools import wraps
911from typing import Any
12+ from unittest .mock import patch
1013
14+ import pytest
15+
16+ from durabletask .internal .entity_state_shim import StateShim
1117from durabletask .serialization import (
1218 DEFAULT_DATA_CONVERTER ,
1319 DataConverter ,
@@ -21,6 +27,284 @@ class Order:
2127 quantity : int
2228
2329
30+ @dataclass (frozen = True )
31+ class PricedOrder :
32+ quantity : int
33+ unit_price : int
34+ total : int = field (init = False )
35+
36+ def __post_init__ (self ):
37+ object .__setattr__ (self , "total" , self .quantity * self .unit_price )
38+
39+
40+ @dataclass
41+ class Shipment :
42+ order : PricedOrder
43+
44+
45+ def test_round_trip_dataclass_with_derived_field ():
46+ converter = JsonDataConverter ()
47+ order = PricedOrder (3 , 10 )
48+ encoded = converter .serialize (order )
49+ assert json .loads (encoded ) == {"quantity" : 3 , "unit_price" : 10 , "total" : 30 }
50+ assert converter .deserialize (encoded , PricedOrder ) == order
51+
52+
53+ def test_coerce_dataclass_recomputes_derived_field ():
54+ converter = JsonDataConverter ()
55+ result = converter .coerce ({"quantity" : 3 , "unit_price" : 10 , "total" : 999 }, PricedOrder )
56+ assert result == PricedOrder (3 , 10 )
57+ assert result .total == 30
58+
59+
60+ def test_round_trip_nested_dataclass_with_derived_field ():
61+ converter = JsonDataConverter ()
62+ shipment = Shipment (PricedOrder (3 , 10 ))
63+ assert converter .deserialize (converter .serialize (shipment ), Shipment ) == shipment
64+
65+
66+ @dataclass
67+ class CounterState :
68+ name : str
69+ counter : int = field (init = False , default = 0 )
70+
71+ def __init__ (self , name : str , counter : int = 0 ):
72+ self .name = name
73+ self .counter = counter
74+
75+
76+ @dataclass
77+ class KeywordCounterState :
78+ name : str
79+ counter : int = field (init = False , default = 0 )
80+
81+ def __init__ (self , name : str , * , counter : int = 0 ):
82+ self .name = name
83+ self .counter = counter
84+
85+
86+ @dataclass
87+ class KwargsCounterState :
88+ name : str
89+ counter : int = field (init = False , default = 0 )
90+
91+ def __init__ (self , name : str , ** kwargs ):
92+ self .name = name
93+ self .counter = kwargs .get ("counter" , 0 )
94+
95+
96+ @pytest .mark .parametrize ("state_type" , [CounterState , KeywordCounterState , KwargsCounterState ])
97+ def test_non_init_field_preserved_by_custom_constructor (state_type ):
98+ converter = JsonDataConverter ()
99+ original = state_type ("persisted" , counter = 42 )
100+ encoded = converter .serialize (original )
101+ restored = converter .deserialize (encoded , state_type )
102+ assert restored == original
103+ assert restored .counter == 42
104+
105+ state = StateShim (encoded , converter , is_serialized = True )
106+ restored_state = state .get_state (state_type )
107+ assert restored_state .counter == 42
108+ state .set_state (restored_state )
109+ assert json .loads (state .encode_state ()) == {"name" : "persisted" , "counter" : 42 }
110+
111+
112+ @pytest .mark .parametrize ("accept_kwargs" , [False , True ])
113+ def test_non_init_field_preserved_by_decorated_initializer (accept_kwargs ):
114+ @dataclass
115+ class DecoratedCounterState :
116+ name : str
117+ counter : int = field (init = False , default = 0 )
118+
119+ generated_init = DecoratedCounterState .__init__
120+ if accept_kwargs :
121+ @wraps (generated_init )
122+ def restore_counter (self , * args , ** kwargs ):
123+ counter = kwargs .pop ("counter" , 0 )
124+ generated_init (self , * args , ** kwargs )
125+ self .counter = counter
126+ else :
127+ @wraps (generated_init )
128+ def restore_counter (self , * args , counter = 0 , ** kwargs ):
129+ generated_init (self , * args , ** kwargs )
130+ self .counter = counter
131+ DecoratedCounterState .__init__ = restore_counter
132+
133+ converter = JsonDataConverter ()
134+ original = DecoratedCounterState ("persisted" , counter = 42 )
135+ encoded = converter .serialize (original )
136+ assert converter .deserialize (encoded , DecoratedCounterState ).counter == 42
137+
138+ state = StateShim (encoded , converter , is_serialized = True )
139+ restored = state .get_state (DecoratedCounterState )
140+ assert restored .counter == 42
141+ state .set_state (restored )
142+ assert json .loads (state .encode_state ()) == {"name" : "persisted" , "counter" : 42 }
143+
144+
145+ @pytest .mark .parametrize ("accept_kwargs" , [False , True ])
146+ def test_non_init_custom_constructor_field_is_recursively_coerced (accept_kwargs ):
147+ @dataclass
148+ class CustomShipment :
149+ order : Order = field (init = False )
150+
151+ if accept_kwargs :
152+ def initializer (self , ** kwargs ):
153+ self .order = kwargs ["order" ]
154+ else :
155+ def initializer (self , * , order ):
156+ self .order = order
157+ CustomShipment .__init__ = initializer
158+
159+ converter = JsonDataConverter ()
160+ restored = converter .deserialize ('{"order": {"item": "book", "quantity": 42}}' , CustomShipment )
161+ assert isinstance (restored , CustomShipment )
162+ assert restored .order == Order ("book" , 42 )
163+
164+
165+ def test_non_init_positional_only_constructor_parameter_is_omitted ():
166+ @dataclass
167+ class PositionalCounter :
168+ counter : int = field (init = False )
169+
170+ def __init__ (self , counter = 0 , / ):
171+ self .counter = counter
172+
173+ restored = JsonDataConverter ().deserialize ('{"counter": 42}' , PositionalCounter )
174+ assert isinstance (restored , PositionalCounter )
175+ assert restored .counter == 0
176+
177+
178+ @pytest .mark .parametrize ("method_type" , [classmethod , staticmethod ])
179+ def test_non_init_field_preserved_by_descriptor_initializer (method_type ):
180+ @dataclass
181+ class DescriptorCounter :
182+ counter : int = field (init = False , default = 0 )
183+
184+ if method_type is classmethod :
185+ def initializer (cls , counter = 0 ):
186+ cls .counter = counter
187+ else :
188+ def initializer (counter = 0 ):
189+ DescriptorCounter .counter = counter
190+ DescriptorCounter .__init__ = method_type (initializer )
191+
192+ converter = JsonDataConverter ()
193+ encoded = converter .serialize (DescriptorCounter (counter = 42 ))
194+ DescriptorCounter .counter = 0
195+ restored = converter .deserialize (encoded , DescriptorCounter )
196+ assert isinstance (restored , DescriptorCounter )
197+ assert restored .counter == 42
198+
199+
200+ def test_non_init_field_matching_receiver_name_is_omitted ():
201+ @dataclass
202+ class ReceiverCounter :
203+ counter : int = field (init = False )
204+
205+ def __init__ (counter ):
206+ counter .counter = 7
207+
208+ restored = JsonDataConverter ().deserialize ('{"counter": 42}' , ReceiverCounter )
209+ assert isinstance (restored , ReceiverCounter )
210+ assert restored .counter == 7
211+
212+
213+ def test_positional_only_receiver_name_can_be_passed_through_kwargs ():
214+ @dataclass
215+ class PositionalReceiver :
216+ self : int = field (init = False , default = 0 )
217+
218+ def __init__ (self , / , ** kwargs ):
219+ self .self = kwargs .get ("self" , 0 )
220+
221+ converter = JsonDataConverter ()
222+ encoded = converter .serialize (PositionalReceiver (** {"self" : 42 }))
223+ restored = converter .deserialize (encoded , PositionalReceiver )
224+ assert isinstance (restored , PositionalReceiver )
225+ assert restored .self == 42
226+
227+
228+ @pytest .mark .parametrize ("hashable" , [True , False ])
229+ def test_non_init_field_preserved_by_callable_initializer (hashable ):
230+ @dataclass
231+ class CallableCounter :
232+ counter : int = field (init = False , default = 0 )
233+
234+ class Initializer :
235+ def __call__ (self , counter = 0 ):
236+ CallableCounter .counter = counter
237+
238+ if not hashable :
239+ Initializer .__hash__ = None
240+ CallableCounter .__init__ = Initializer ()
241+
242+ converter = JsonDataConverter ()
243+ encoded = converter .serialize (CallableCounter (counter = 42 ))
244+ CallableCounter .counter = 0
245+ restored = converter .deserialize (encoded , CallableCounter )
246+ assert isinstance (restored , CallableCounter )
247+ assert restored .counter == 42
248+
249+
250+ @pytest .mark .parametrize ("error_type" , [TypeError , ValueError ])
251+ def test_non_init_fields_retained_when_constructor_signature_unavailable (error_type ):
252+ @dataclass
253+ class UninspectableCounter :
254+ counter : int = field (init = False )
255+
256+ def __init__ (self , counter = 0 ):
257+ self .counter = counter
258+
259+ with patch ("durabletask.serialization.inspect.signature" , side_effect = error_type ):
260+ restored = JsonDataConverter ().deserialize ('{"counter": 42}' , UninspectableCounter )
261+ assert isinstance (restored , UninspectableCounter )
262+ assert restored .counter == 42
263+
264+
265+ def test_dataclass_constructor_typeerror_is_not_retried ():
266+ calls = []
267+
268+ @dataclass
269+ class FailingCounter :
270+ counter : int = field (init = False )
271+
272+ def __init__ (self , counter = 0 ):
273+ calls .append (counter )
274+ raise TypeError ("failure inside user constructor" )
275+
276+ restored = JsonDataConverter ().deserialize ('{"counter": 42}' , FailingCounter )
277+ assert restored == {"counter" : 42 }
278+ assert calls == [42 ]
279+
280+
281+ def test_ordinary_dataclass_skips_constructor_signature_inspection ():
282+ with patch ("durabletask.serialization.inspect.signature" , side_effect = AssertionError ("unexpected inspection" )):
283+ restored = JsonDataConverter ().deserialize ('{"item": "book", "quantity": 42}' , Order )
284+ assert restored == Order ("book" , 42 )
285+
286+
287+ def test_constructor_signature_cached_by_initializer ():
288+ @dataclass
289+ class SharedInitializer :
290+ counter : int = field (init = False )
291+
292+ def __init__ (self , counter = 0 ):
293+ self .counter = counter
294+
295+ @dataclass (init = False )
296+ class InheritedInitializer (SharedInitializer ):
297+ pass
298+
299+ converter = JsonDataConverter ()
300+ with patch ("durabletask.serialization.inspect.signature" , wraps = inspect .signature ) as signature :
301+ for cls in (SharedInitializer , InheritedInitializer , SharedInitializer ):
302+ restored = converter .deserialize ('{"counter": 42}' , cls )
303+ assert isinstance (restored , cls )
304+ assert restored .counter == 42
305+ signature .assert_called_once_with (SharedInitializer .__init__ , follow_wrapped = False )
306+
307+
24308# ----- JsonDataConverter -----
25309
26310
0 commit comments