Skip to content

Commit 42ed8fb

Browse files
andystaplesCopilot
andcommitted
Merge latest main into shared task references fix
Preserve both Unreleased changelog entries and the shared-task replay migration warning. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2 parents bb2a2bc + a066f19 commit 42ed8fb

3 files changed

Lines changed: 345 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ completion callback no longer prevents other observers from being notified.
3333
> dependency, including when using a provider. Provider dependency minimums
3434
> will be coordinated in a dedicated release PR after the core fix is released.
3535
36+
- Dataclasses with `init=False` fields now reconstruct as their declared type
37+
instead of falling back to a raw dictionary when their generated constructor
38+
cannot accept those fields. Handwritten constructors continue to receive
39+
serialized non-init fields they accept as keywords, including through
40+
`**kwargs`. Other non-init fields use defaults, default factories, or
41+
`__post_init__` and may reset previously recorded values. Reconstruction must
42+
be deterministic for replay; use an explicit `from_json()` hook to preserve
43+
recorded state that the constructor cannot accept.
3644
- Single-instance `purge_orchestration()` requests now explicitly target
3745
orchestrations rather than entities in both synchronous and asynchronous clients.
3846
- Timer callbacks no longer schedule additional long-timer chunks, retry

‎durabletask/serialization.py‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,16 @@ class JsonDataConverter(DataConverter):
144144
keeps the core SDK permissive; a stricter, validating converter can be
145145
supplied for callers who want coercion failures to surface as errors.
146146
147+
Dataclass fields marked ``init=False`` are passed to a handwritten
148+
initializer when it accepts them as keywords, including through
149+
``**kwargs``. Otherwise they are omitted: generated initializers use
150+
defaults and default factories, and ``__post_init__`` can recompute derived
151+
values. Previously recorded non-init values may therefore be reset, or
152+
remain unset if the class initializes them externally. Constructors,
153+
default factories, and ``__post_init__`` must behave deterministically for
154+
replay. Define an explicit ``from_json()`` hook to preserve recorded
155+
non-init state that the initializer cannot accept.
156+
147157
> [!NOTE]
148158
> Type-directed reconstruction recurses through dataclass fields,
149159
> ``list``/``Sequence``, ``dict``/``Mapping`` values, ``tuple`` elements,
@@ -494,6 +504,32 @@ def _coerce_generic(value: Any, expected_type: Any, origin: Any,
494504
return value
495505

496506

507+
@functools.lru_cache(maxsize=256)
508+
def _dataclass_init_keywords(initializer: Any) -> tuple[frozenset[str] | None, str | None]:
509+
"""Return keyword names and a potentially reserved receiver name.
510+
511+
A None keyword set represents **kwargs or an unavailable signature.
512+
The caller accounts for receiver binding when using the reserved name.
513+
"""
514+
try:
515+
parameters = list(inspect.signature(initializer, follow_wrapped=False).parameters.values())
516+
except (TypeError, ValueError):
517+
# Preserve the legacy keyword-passing behavior when inspection fails.
518+
return None, None
519+
receiver = None
520+
# Positional-only receiver names remain available as keys in **kwargs.
521+
if parameters and parameters[0].kind is inspect.Parameter.POSITIONAL_OR_KEYWORD:
522+
receiver = parameters[0].name
523+
if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in parameters):
524+
return None, receiver
525+
keywords = frozenset(
526+
p.name for p in parameters
527+
if p.kind in (inspect.Parameter.POSITIONAL_OR_KEYWORD,
528+
inspect.Parameter.KEYWORD_ONLY)
529+
)
530+
return keywords, receiver
531+
532+
497533
def _build_dataclass(cls: Any, data: dict[str, Any],
498534
converter: DataConverter | None = None) -> Any:
499535
"""Construct a dataclass from its dict payload, recursing into typed fields."""
@@ -506,6 +542,22 @@ def _build_dataclass(cls: Any, data: dict[str, Any],
506542
for field in dataclasses.fields(cls):
507543
if field.name not in data:
508544
continue
545+
if not field.init:
546+
initializer = cls.__init__
547+
try:
548+
init_keywords, receiver = _dataclass_init_keywords(initializer)
549+
except TypeError:
550+
# Unhashable initializer callables cannot use the cache.
551+
init_keywords, receiver = None, None
552+
if init_keywords is not None and field.name not in init_keywords:
553+
continue
554+
if (field.name == receiver
555+
and (inspect.isfunction(initializer) or inspect.ismethoddescriptor(initializer))
556+
and not isinstance(inspect.getattr_static(cls, "__init__"), staticmethod)):
557+
# Normal instance initializers already receive this argument.
558+
# Bound classmethods omit it from their inspected signature;
559+
# staticmethods have no implicit receiver.
560+
continue
509561
# ``get_type_hints`` on Python 3.10 does not deep-resolve forward
510562
# references nested inside container args (e.g. the ``"TreeNode"`` in
511563
# ``list["TreeNode"]`` on a self-referential dataclass), leaving a bare

‎tests/durabletask/test_data_converter.py‎

Lines changed: 285 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33

44
"""Tests for the DataConverter abstraction and the default JsonDataConverter."""
55

6+
import inspect
67
import json
78
import logging
8-
from dataclasses import dataclass
9+
from dataclasses import dataclass, field
10+
from functools import wraps
911
from typing import Any
12+
from unittest.mock import patch
1013

14+
import pytest
15+
16+
from durabletask.internal.entity_state_shim import StateShim
1117
from 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

Comments
 (0)