Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

FIXED

- Type discovery now recognizes `dict` / `Mapping` and `tuple` 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
automatically reconstructed into typed model instances without requiring an explicit
`return_type` override.

> [!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.
> Existing instances that relied on raw dictionaries or lists can change behavior
> or fail replay with `NonDeterminismError`.
>
> Drain affected instances on the previous SDK, or keep their task hub on the
> previous SDK and use a separate task hub for new instances. Pin or lock the core
> `durabletask` dependency, including when using the Azure Managed provider.

## v1.13.1

ADDED
Expand Down
8 changes: 8 additions & 0 deletions durabletask-azuremanaged/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

FIXED

- 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.

## v1.13.1

ADDED
Expand Down
23 changes: 15 additions & 8 deletions durabletask/serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,15 +267,14 @@ def _is_supported_set_element(converter: DataConverter, elem_type: Any) -> bool:
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`` 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
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`` /
``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``
still participates in the element-type checks of ``Optional`` / ``list``
hints.
still participates in the element-type checks of wrapped hints.
"""
origin = typing.get_origin(target_type)
if origin is not None:
Expand All @@ -292,6 +291,14 @@ 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):
return len(args) == 2 and converter.can_reconstruct(args[1])
if origin is tuple:
if not args:
return False
if len(args) == 2 and args[1] is Ellipsis:
return converter.can_reconstruct(args[0])
return any(converter.can_reconstruct(a) for a in args)
return False
if not isinstance(target_type, type):
return False
Expand Down
51 changes: 51 additions & 0 deletions tests/azure-functions-durable/test_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -361,3 +361,54 @@ def test_durable_client_decode_preserves_host_configuration():
value = DurableClientConverter.decode(
meta.Datum(type="string", value=config), trigger_metadata=None)
assert value == config


# ---------------------------------------------------------------------------
# FunctionsDataConverter reconstruction and discovery
# ---------------------------------------------------------------------------

def test_functions_data_converter_can_reconstruct_and_discovery_unaffected():
"""Azure Functions overrides can_reconstruct() to return True uniformly.

The core SDK's expanded dict/tuple traversal in JsonDataConverter does not
alter Azure Functions discovery behavior, because FunctionsDataConverter
already returns True for all types (delegating decoding to df_loads).
"""
from collections.abc import Mapping
from dataclasses import dataclass
from durabletask.internal import type_discovery

@dataclass
class Order:
item: str
quantity: int

conv = FunctionsDataConverter()

# Always returns True for affected container types
assert conv.can_reconstruct(dict[str, Order]) is True
assert conv.can_reconstruct(Mapping[str, Order]) is True
assert conv.can_reconstruct(tuple[Order, ...]) is True
assert conv.can_reconstruct(tuple[Order, int]) is True

# Always returns True even for builtins and unknown types (distinct from core)
assert conv.can_reconstruct(dict[str, int]) is True
assert conv.can_reconstruct(tuple[int, str]) is True
assert conv.can_reconstruct(int) is True
assert conv.can_reconstruct(dict) is True
assert conv.can_reconstruct(tuple) is True

# Inbound and outbound type discovery with FunctionsDataConverter hands declared types through
def sample_func(ctx, orders: dict[str, Order]) -> tuple[Order, ...]:
pass

assert type_discovery.orchestrator_input_type(sample_func, conv) == dict[str, Order]
assert type_discovery.activity_input_type(sample_func, conv) == dict[str, Order]
assert type_discovery.activity_output_type(sample_func, conv) == tuple[Order, ...]

def builtin_func(ctx, data: dict[str, int]) -> tuple[int, ...]:
pass

# Builtins are also passed through to df_loads under FunctionsDataConverter
assert type_discovery.orchestrator_input_type(builtin_func, conv) == dict[str, int]
assert type_discovery.activity_output_type(builtin_func, conv) == tuple[int, ...]
Loading
Loading