Skip to content
8 changes: 5 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ orchestration time, preventing retries from expiring solely because history is
replayed later. Retry calculations also accept timezone-aware timestamps.
Histories that recorded timeout failures based on wall-clock time can replay
differently; drain affected instances on the previous SDK before upgrading.
- Type discovery now recognizes `dict` / `Mapping` and `tuple` containers wrapping
- Type discovery now recognizes `dict` / `Mapping` / `MutableMapping`,
`tuple`, and `list` / `Sequence` / `MutableSequence` containers wrapping
reconstructable types (such as dataclasses and `from_json()`-capable types) via
`JsonDataConverter.can_reconstruct()`. Inbound activity and orchestrator inputs as
well as inferred activity return types annotated with these containers are now
Expand All @@ -23,8 +24,9 @@ automatically reconstructed into typed model instances without requiring an expl

> [!WARNING]
> **Replay-breaking bug fix:** Container annotations involving `dict`, `Mapping`,
> or `tuple` wrapping reconstructable types, including nested annotations, now
> reconstruct orchestration inputs and inferred activity results by default.
> `MutableMapping`, `list`, `Sequence`, `MutableSequence`, or `tuple` wrapping
> reconstructable types, including nested annotations, now reconstruct
> orchestration inputs and inferred activity results by default.
> Existing instances that relied on raw dictionaries or lists can change behavior
> or fail replay with `NonDeterminismError`.
>
Expand Down
9 changes: 5 additions & 4 deletions durabletask-azuremanaged/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ FIXED
wall-clock time. See the [core upgrade guidance](../CHANGELOG.md#unreleased)
for histories that recorded timeout failures under the previous behavior.
- With the corresponding core `durabletask` SDK update, type discovery now recognizes
`dict`, `Mapping`, and `tuple` container annotations wrapping reconstructable types,
automatically reconstructing recorded inputs and inferred activity results into typed model
instances. See the [core upgrade warning](../CHANGELOG.md#unreleased) for replay-breaking
guidance and dependency pinning recommendations.
`dict`, `Mapping`, `MutableMapping`, `list`, `Sequence`, `MutableSequence`, and `tuple`
container annotations wrapping reconstructable types, automatically reconstructing recorded
inputs and inferred activity results into typed model instances. See the
[core upgrade warning](../CHANGELOG.md#unreleased) for replay-breaking guidance and
dependency pinning recommendations.

## v1.13.1

Expand Down
16 changes: 8 additions & 8 deletions durabletask/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
import types
import typing
from abc import ABC, abstractmethod
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Iterable, Mapping, MutableMapping, MutableSequence, Sequence
from collections.abc import Set as AbstractSet
from types import SimpleNamespace
from typing import Any, cast
Expand Down Expand Up @@ -268,9 +268,9 @@ def _can_reconstruct(converter: DataConverter, target_type: Any) -> bool:
""":class:`JsonDataConverter`'s reconstruction policy.

Recognizes dataclasses and ``from_json()``-capable types, plus ``Optional``,
``list`` / ``Sequence``, ``dict`` / ``Mapping``, and ``tuple`` hints wrapping
them; builtins and unknown annotations are excluded. When set support is
enabled on the converter (via ``enable_generic_sets=True``), ``set`` /
``list`` / ``Sequence`` / ``MutableSequence``, ``dict`` / ``Mapping`` /
``MutableMapping``, and ``tuple`` hints wrapping them; builtins and unknown
annotations are excluded. When set support is enabled on the converter (via ``enable_generic_sets=True``), ``set`` /
``frozenset`` / ``Set`` containers with supported element types are also
recognized. Recurses through ``converter.can_reconstruct`` (not itself) so a
:class:`JsonDataConverter` subclass that overrides ``can_reconstruct``
Expand All @@ -283,15 +283,15 @@ def _can_reconstruct(converter: DataConverter, target_type: Any) -> bool:
return any(
converter.can_reconstruct(a) for a in args if a is not type(None)
)
if origin in (list, Sequence):
if origin in (list, Sequence, MutableSequence):
return any(converter.can_reconstruct(a) for a in args)
if origin in (set, frozenset, AbstractSet, typing.AbstractSet):
if not getattr(converter, "enable_generic_sets", False):
return False
if not args:
return True
return _is_supported_set_element(converter, args[0])
if origin in (dict, Mapping):
if origin in (dict, Mapping, MutableMapping):
return len(args) == 2 and converter.can_reconstruct(args[1])
if origin is tuple:
if not args:
Expand Down Expand Up @@ -611,7 +611,7 @@ def _coerce_generic(value: Any, expected_type: Any, origin: Any,
if len(non_none) == 1:
return _coerce_to_type(value, non_none[0], converter)
return value
if origin in (list, Sequence) and isinstance(value, list):
if origin in (list, Sequence, MutableSequence) and isinstance(value, list):
elem_type = args[0] if args else None
return [_coerce_to_type(item, elem_type, converter) for item in cast(list[Any], value)]
if origin in (set, frozenset, AbstractSet, typing.AbstractSet) and isinstance(
Expand All @@ -627,7 +627,7 @@ def _coerce_generic(value: Any, expected_type: Any, origin: Any,
if origin is frozenset:
return frozenset(coerced)
return set(coerced)
if origin in (dict, Mapping) and isinstance(value, dict):
if origin in (dict, Mapping, MutableMapping) and isinstance(value, dict):
# JSON object keys are always strings, so only the values can carry a
# reconstructable type. Keys are passed through unchanged.
mapping = cast("dict[Any, Any]", value)
Expand Down
17 changes: 17 additions & 0 deletions tests/durabletask/test_data_converter_roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from __future__ import annotations

from collections.abc import MutableMapping, MutableSequence
import enum
import typing
from dataclasses import dataclass, field
Expand Down Expand Up @@ -371,12 +372,28 @@ def test_list_of_dataclasses(conv):
assert result == value


def test_mutable_sequence_of_dataclasses(conv):
value = [Address("a", "b"), Address("c", "d")]
result = _round_trip(conv, value, MutableSequence[Address])
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(item, Address) for item in result)
assert result == value


def test_dict_of_dataclasses(conv):
value = {"home": Address("a", "b")}
result = _round_trip(conv, value, dict[str, Address])
assert isinstance(result["home"], Address)


def test_mutable_mapping_of_dataclasses(conv):
value = {"home": Address("a", "b")}
result = _round_trip(conv, value, MutableMapping[str, Address])
assert isinstance(result["home"], Address)
assert result == value


def test_dict_of_optional_dataclasses(conv):
value = {"a": Address("x", "y"), "b": None}
result = _round_trip(conv, value, dict[str, typing.Optional[Address]])
Expand Down
49 changes: 48 additions & 1 deletion tests/durabletask/test_type_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import inspect
import json
import logging
from collections.abc import Mapping
from collections.abc import Mapping, MutableMapping, MutableSequence
from dataclasses import dataclass
from typing import Any, Optional, get_origin
from unittest.mock import patch
Expand Down Expand Up @@ -69,14 +69,18 @@ def test_optional_dataclass_is_reconstructable(self):

def test_list_of_dataclass_is_reconstructable(self):
assert self.conv.can_reconstruct(list[Order]) is True
assert self.conv.can_reconstruct(MutableSequence[Order]) is True

def test_list_of_builtin_is_not_reconstructable(self):
assert self.conv.can_reconstruct(list[int]) is False
assert self.conv.can_reconstruct(MutableSequence[int]) is False

def test_dict_and_mapping_of_dataclass_is_reconstructable(self):
assert self.conv.can_reconstruct(dict[str, Order]) is True
assert self.conv.can_reconstruct(Mapping[str, Order]) is True
assert self.conv.can_reconstruct(MutableMapping[str, Order]) is True
assert self.conv.can_reconstruct(dict[str, int]) is False
assert self.conv.can_reconstruct(MutableMapping[str, int]) is False

def test_tuple_of_dataclass_is_reconstructable(self):
assert self.conv.can_reconstruct(tuple[Order, ...]) is True
Expand Down Expand Up @@ -110,11 +114,13 @@ def can_reconstruct(self, target_type: Any) -> bool:
# The base Optional/list recursion goes through ``self``, so the override
# is consulted for the element type.
assert conv.can_reconstruct(list[Widget]) is True
assert conv.can_reconstruct(MutableSequence[Widget]) is True
assert conv.can_reconstruct(Optional[Widget]) is True
# Builtins remain excluded.
assert conv.can_reconstruct(int) is False
# The override is also consulted for elements inside dict and tuple hints.
assert conv.can_reconstruct(dict[str, Widget]) is True
assert conv.can_reconstruct(MutableMapping[str, Widget]) is True
assert conv.can_reconstruct(tuple[Widget, ...]) is True
assert conv.can_reconstruct(tuple[Widget, int]) is True

Expand Down Expand Up @@ -515,6 +521,47 @@ def handle(ctx: task.ActivityContext, orders: tuple[Order, ...]):
assert json.loads(result) == 2


def test_activity_input_coerced_for_mutable_mapping_of_dataclass():
seen: dict[str, Any] = {}

def handle(ctx: task.ActivityContext, orders: MutableMapping[str, Order]):
seen["orders"] = orders
return sum(o.quantity for o in orders.values())

executor, name = _activity_executor(handle)
payload = json.dumps({"o1": {"item": "widget", "quantity": 3}, "o2": {"item": "gadget", "quantity": 7}})
result = executor.execute("orch1", name, 1, payload)

orders = seen["orders"]
assert isinstance(orders, MutableMapping)
assert isinstance(orders["o1"], Order)
assert isinstance(orders["o2"], Order)
assert orders["o1"].item == "widget"
assert orders["o2"].quantity == 7
assert json.loads(result) == 10


def test_activity_input_coerced_for_mutable_sequence_of_dataclass():
seen: dict[str, Any] = {}

def handle(ctx: task.ActivityContext, orders: MutableSequence[Order]):
seen["orders"] = orders
return len(orders)

executor, name = _activity_executor(handle)
payload = json.dumps([{"item": "widget", "quantity": 3}, {"item": "gadget", "quantity": 7}])
result = executor.execute("orch1", name, 1, payload)

orders = seen["orders"]
assert isinstance(orders, MutableSequence)
assert len(orders) == 2
assert isinstance(orders[0], Order)
assert isinstance(orders[1], Order)
assert orders[0].item == "widget"
assert orders[1].quantity == 7
assert json.loads(result) == 2


def test_activity_builtin_input_unchanged():
seen: dict[str, Any] = {}

Expand Down