Skip to content

Commit 6be2af1

Browse files
committed
Add pydantic example, fix reconstructibility concern
1 parent 384dafd commit 6be2af1

14 files changed

Lines changed: 800 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ ADDED
4646
`DataConverter` as a second parameter (`from_json(cls, value, converter)`),
4747
letting it reconstruct nested typed values via `converter.coerce(...)` /
4848
`converter.deserialize(...)`. The single-argument form remains supported.
49+
- `DataConverter` now exposes an overridable `is_reconstructable(target_type)`
50+
method that controls which annotated input/return types the SDK reconstructs
51+
on the inbound path. A custom converter can override it to recognize its own
52+
types (for example `pydantic.BaseModel` subclasses), so that orchestrator /
53+
activity / entity inputs annotated with those types are reconstructed by the
54+
converter instead of arriving as raw JSON. The default behavior is unchanged
55+
(dataclasses and `from_json()`-capable types, plus `Optional` / `list`
56+
wrappers, are reconstructable; builtins are not).
4957
- Added `EntityMetadata.get_typed_state(intended_type=...)`, which deserializes
5058
the entity's persisted state and reconstructs dataclasses and
5159
`from_json()`-capable types. The existing `get_state()` is unchanged: with no

durabletask/internal/type_discovery.py

Lines changed: 36 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,54 +5,36 @@
55
66
These helpers resolve the annotation of the *input* parameter of an
77
orchestrator, activity, or entity function so that inbound payloads can be
8-
reconstructed into the annotated custom type (a dataclass or a type exposing a
9-
``from_json()`` classmethod) without the caller having to pass an explicit type.
8+
reconstructed into the annotated custom type without the caller having to pass
9+
an explicit type.
1010
1111
Discovery is intentionally conservative: it only returns an annotation when the
12-
target is a *reconstructable* custom type (a dataclass, a ``from_json()``-capable
13-
type, or an ``Optional`` / ``list`` wrapping one). Primitive and unknown
14-
annotations resolve to ``None`` so that existing payloads are passed through
15-
unchanged -- inbound type discovery never invokes an arbitrary constructor on
16-
untrusted data, and never alters the value for builtins.
12+
active :class:`~durabletask.serialization.DataConverter` reports it as
13+
*reconstructable* via :meth:`DataConverter.is_reconstructable`. The default
14+
converter recognizes dataclasses, ``from_json()``-capable types, and ``Optional``
15+
/ ``list`` hints wrapping them; a custom converter can recognize its own types
16+
(e.g. ``pydantic.BaseModel``). Primitive and unknown annotations resolve to
17+
``None`` so that existing payloads are passed through unchanged -- inbound type
18+
discovery never invokes an arbitrary constructor on untrusted data, and never
19+
alters the value for builtins.
1720
1821
All public helpers swallow exceptions and return ``None`` on failure; the caller
1922
treats ``None`` as "no type information available" and uses the raw payload.
2023
"""
2124

2225
from __future__ import annotations
2326

24-
import collections.abc
25-
import dataclasses
2627
import functools
2728
import inspect
28-
import types
2929
import typing
30-
from typing import Any, Callable, cast
30+
from typing import Any, Callable
3131

32+
from durabletask.serialization import DEFAULT_DATA_CONVERTER, DataConverter
3233

33-
def is_reconstructable(annotation: Any) -> bool:
34-
"""Return True if ``annotation`` names a custom type we can rebuild.
3534

36-
Reconstructable targets are dataclasses, types exposing a callable
37-
``from_json``, and ``Optional`` / ``list`` hints wrapping such types.
38-
Builtins (``int``, ``str``, ``dict``, ...) and unknown annotations are not
39-
reconstructable and resolve to ``False``.
40-
"""
41-
origin = typing.get_origin(annotation)
42-
if origin is not None:
43-
args = typing.get_args(annotation)
44-
if origin is typing.Union or origin is types.UnionType:
45-
return any(
46-
is_reconstructable(a) for a in args if a is not type(None)
47-
)
48-
if origin in (list, collections.abc.Sequence):
49-
return any(is_reconstructable(a) for a in args)
50-
return False
51-
if not isinstance(annotation, type):
52-
return False
53-
if dataclasses.is_dataclass(annotation):
54-
return True
55-
return callable(getattr(cast(Any, annotation), "from_json", None))
35+
def _resolve_converter(converter: DataConverter | None) -> DataConverter:
36+
"""Return the supplied converter, or the shared default when ``None``."""
37+
return converter if converter is not None else DEFAULT_DATA_CONVERTER
5638

5739

5840
# Bounded so a worker that registers dynamically-created functions or closures
@@ -72,14 +54,15 @@ def _resolved_hints(fn: Callable[..., Any]) -> dict[str, Any] | None:
7254
return None
7355

7456

75-
def _input_annotation(fn: Callable[..., Any], position: int) -> Any | None:
57+
def _input_annotation(fn: Callable[..., Any], position: int,
58+
converter: DataConverter | None = None) -> Any | None:
7659
"""Return the resolved annotation of the positional parameter at ``position``.
7760
7861
``position`` is the zero-based index among positional parameters (so the
7962
``input`` parameter of a ``(ctx, input)`` function is at position 1, and the
8063
``input`` parameter of an unbound ``(self, input)`` entity method is also at
8164
position 1). Returns ``None`` when the parameter is absent, unannotated, or
82-
its annotation is not a reconstructable custom type.
65+
its annotation is not reconstructable by ``converter``.
8366
"""
8467
try:
8568
sig = inspect.signature(fn)
@@ -105,27 +88,29 @@ def _input_annotation(fn: Callable[..., Any], position: int) -> Any | None:
10588

10689
if annotation is inspect.Parameter.empty or annotation is Any:
10790
return None
108-
return annotation if is_reconstructable(annotation) else None
91+
return annotation if _resolve_converter(converter).is_reconstructable(annotation) else None
10992

11093

111-
def orchestrator_input_type(fn: Callable[..., Any]) -> Any | None:
94+
def orchestrator_input_type(fn: Callable[..., Any],
95+
converter: DataConverter | None = None) -> Any | None:
11296
"""Discover the input type of an orchestrator function ``(ctx, input)``."""
113-
return _input_annotation(fn, 1)
97+
return _input_annotation(fn, 1, converter)
11498

11599

116-
def activity_input_type(fn: Callable[..., Any]) -> Any | None:
100+
def activity_input_type(fn: Callable[..., Any],
101+
converter: DataConverter | None = None) -> Any | None:
117102
"""Discover the input type of an activity function ``(ctx, input)``."""
118-
return _input_annotation(fn, 1)
103+
return _input_annotation(fn, 1, converter)
119104

120105

121-
def activity_output_type(fn: Any) -> Any | None:
106+
def activity_output_type(fn: Any, converter: DataConverter | None = None) -> Any | None:
122107
"""Discover the return type of an activity function.
123108
124-
Returns the resolved return annotation when it names a reconstructable
125-
custom type (a dataclass or a ``from_json()``-capable type, optionally
126-
wrapped in ``Optional`` / ``list``). Returns ``None`` for plain callables
127-
that are not annotated with such a type, for string activity names, or when
128-
the annotation cannot be resolved.
109+
Returns the resolved return annotation when ``converter`` reports it as
110+
reconstructable (the default converter recognizes a dataclass or a
111+
``from_json()``-capable type, optionally wrapped in ``Optional`` / ``list``).
112+
Returns ``None`` for plain callables that are not annotated with such a type,
113+
for string activity names, or when the annotation cannot be resolved.
129114
"""
130115
if not callable(fn):
131116
return None
@@ -144,10 +129,11 @@ def activity_output_type(fn: Any) -> Any | None:
144129

145130
if annotation is inspect.Signature.empty or annotation is Any or annotation is None:
146131
return None
147-
return annotation if is_reconstructable(annotation) else None
132+
return annotation if _resolve_converter(converter).is_reconstructable(annotation) else None
148133

149134

150-
def entity_input_type(fn: Any, operation: str) -> Any | None:
135+
def entity_input_type(fn: Any, operation: str,
136+
converter: DataConverter | None = None) -> Any | None:
151137
"""Discover the input type of an entity operation.
152138
153139
For class-based entities (a ``DurableEntity`` subclass) the operation is a
@@ -160,5 +146,5 @@ def entity_input_type(fn: Any, operation: str) -> Any | None:
160146
if method is None or not callable(method):
161147
return None
162148
# Unbound method includes ``self`` at position 0, so ``input`` is at 1.
163-
return _input_annotation(method, 1)
164-
return _input_annotation(fn, 1)
149+
return _input_annotation(method, 1, converter)
150+
return _input_annotation(fn, 1, converter)

durabletask/serialization.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,30 @@ def coerce(self, value: Any, target_type: type | None = None) -> Any:
101101
"""
102102
...
103103

104+
def is_reconstructable(self, target_type: Any) -> bool:
105+
"""Return True if this converter can rebuild ``target_type`` from a payload.
106+
107+
Inbound type-discovery calls this to decide whether a function's
108+
annotated *input* type (or an activity's *return* annotation) should be
109+
passed to :meth:`deserialize` / :meth:`coerce`. When it returns ``False``
110+
the SDK passes the raw deserialized payload through unchanged -- this
111+
gate is what stops the SDK from invoking an arbitrary constructor on a
112+
builtin or otherwise unrecognized annotation.
113+
114+
The default recognizes the types the built-in codec can rebuild --
115+
dataclasses and ``from_json()``-capable types, plus ``Optional`` /
116+
``list`` / ``Sequence`` hints wrapping them -- and excludes builtins
117+
(``int``, ``str``, ``dict``, ...) and unknown annotations.
118+
119+
Override this to teach the SDK about a custom converter's own types (for
120+
example ``pydantic.BaseModel`` subclasses) so that inputs annotated with
121+
them are reconstructed instead of arriving as raw JSON. The default
122+
implementation recurses through ``self.is_reconstructable``, so an
123+
override is also consulted for the element types of ``Optional`` /
124+
``list`` hints (e.g. ``list[MyModel]``).
125+
"""
126+
return _is_reconstructable(self, target_type)
127+
104128

105129
class JsonDataConverter(DataConverter):
106130
"""Default :class:`DataConverter` backed by the SDK's JSON codec.
@@ -187,6 +211,32 @@ def _log_coercion_fallback(target_type: type, error: Exception) -> None:
187211
# ---------------------------------------------------------------------------
188212

189213

214+
def _is_reconstructable(converter: DataConverter, target_type: Any) -> bool:
215+
"""Default :meth:`DataConverter.is_reconstructable` policy.
216+
217+
Recognizes dataclasses and ``from_json()``-capable types, plus ``Optional``
218+
/ ``list`` / ``Sequence`` hints wrapping them; builtins and unknown
219+
annotations are excluded. Recurses through ``converter.is_reconstructable``
220+
(not itself) so a subclass override participates in the element-type checks
221+
of ``Optional`` / ``list`` hints.
222+
"""
223+
origin = typing.get_origin(target_type)
224+
if origin is not None:
225+
args = typing.get_args(target_type)
226+
if origin is typing.Union or origin is types.UnionType:
227+
return any(
228+
converter.is_reconstructable(a) for a in args if a is not type(None)
229+
)
230+
if origin in (list, Sequence):
231+
return any(converter.is_reconstructable(a) for a in args)
232+
return False
233+
if not isinstance(target_type, type):
234+
return False
235+
if dataclasses.is_dataclass(target_type):
236+
return True
237+
return callable(getattr(cast(Any, target_type), "from_json", None))
238+
239+
190240
def _to_json(obj: Any) -> str:
191241
"""Serialize a value to a JSON string.
192242

durabletask/worker.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1692,7 +1692,7 @@ def call_activity(
16921692
# converter decides how a coercion failure is handled (the default is
16931693
# best-effort).
16941694
if return_type is None and not isinstance(activity, str):
1695-
return_type = type_discovery.activity_output_type(activity)
1695+
return_type = type_discovery.activity_output_type(activity, self._data_converter)
16961696

16971697
self.call_activity_function_helper(
16981698
id, activity, input=input, retry_policy=retry_policy, is_sub_orch=False, tags=tags,
@@ -2211,7 +2211,7 @@ def process_event(
22112211
if (
22122212
event.executionStarted.HasField("input") and event.executionStarted.input.value != ""
22132213
):
2214-
input_type = type_discovery.orchestrator_input_type(fn)
2214+
input_type = type_discovery.orchestrator_input_type(fn, self._data_converter)
22152215
input = self._data_converter.deserialize(
22162216
event.executionStarted.input.value, input_type)
22172217

@@ -2856,7 +2856,7 @@ def execute(
28562856
f"Activity function named '{name}' was not registered!"
28572857
)
28582858

2859-
input_type = type_discovery.activity_input_type(fn) if encoded_input else None
2859+
input_type = type_discovery.activity_input_type(fn, self._data_converter) if encoded_input else None
28602860
activity_input = self._data_converter.deserialize(encoded_input, input_type)
28612861
ctx = task.ActivityContext(orchestration_id, task_id)
28622862

@@ -2897,7 +2897,7 @@ def execute(
28972897
f"Entity function named '{entity_id.entity}' was not registered!"
28982898
)
28992899

2900-
input_type = type_discovery.entity_input_type(fn, operation) if encoded_input else None
2900+
input_type = type_discovery.entity_input_type(fn, operation, self._data_converter) if encoded_input else None
29012901
entity_input = self._data_converter.deserialize(encoded_input, input_type)
29022902
ctx = EntityContext(orchestration_id, operation, state, entity_id, self._data_converter)
29032903

examples/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ python activity_sequence.py
169169
> account and an additional install step. See the
170170
> [large payload README](./large_payload/README.md) for details.
171171
172+
> [!NOTE]
173+
> The `custom_data_converter/` example is a self-contained subproject that
174+
> shows how to plug a third-party serialization library (pydantic) into the
175+
> SDK via a custom `DataConverter`, with in-process tests that need no backend.
176+
> See the [custom DataConverter README](./custom_data_converter/README.md).
177+
172178
### Review Orchestration History and Status
173179

174180
To access the Durable Task Scheduler Dashboard, follow these steps:

0 commit comments

Comments
 (0)