diff --git a/CHANGELOG.md b/CHANGELOG.md index 40aa2e88..9a64b677 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -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`. > diff --git a/durabletask-azuremanaged/CHANGELOG.md b/durabletask-azuremanaged/CHANGELOG.md index 1b327beb..2f3ce7a9 100644 --- a/durabletask-azuremanaged/CHANGELOG.md +++ b/durabletask-azuremanaged/CHANGELOG.md @@ -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 diff --git a/durabletask/serialization.py b/durabletask/serialization.py index 7f10cae8..9c0bd0a0 100644 --- a/durabletask/serialization.py +++ b/durabletask/serialization.py @@ -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 @@ -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`` @@ -283,7 +283,7 @@ 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): @@ -291,7 +291,7 @@ def _can_reconstruct(converter: DataConverter, target_type: Any) -> bool: 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: @@ -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( @@ -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) diff --git a/tests/durabletask/test_data_converter_roundtrip.py b/tests/durabletask/test_data_converter_roundtrip.py index 804f81bf..aab0ab59 100644 --- a/tests/durabletask/test_data_converter_roundtrip.py +++ b/tests/durabletask/test_data_converter_roundtrip.py @@ -19,6 +19,7 @@ from __future__ import annotations +from collections.abc import MutableMapping, MutableSequence import enum import typing from dataclasses import dataclass, field @@ -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]]) diff --git a/tests/durabletask/test_type_discovery.py b/tests/durabletask/test_type_discovery.py index 07fbe8f6..90e2640b 100644 --- a/tests/durabletask/test_type_discovery.py +++ b/tests/durabletask/test_type_discovery.py @@ -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 @@ -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 @@ -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 @@ -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] = {}