Skip to content

Commit f0ea403

Browse files
committed
More fixes:
- Rename is_reconstructible to can_reconstruct - Correct ownership of _can_reconstruct - Required DataConverter for internal classes
1 parent 6be2af1 commit f0ea403

11 files changed

Lines changed: 151 additions & 146 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ 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)`
49+
- `DataConverter` now exposes an overridable `can_reconstruct(target_type)`
5050
method that controls which annotated input/return types the SDK reconstructs
5151
on the inbound path. A custom converter can override it to recognize its own
5252
types (for example `pydantic.BaseModel` subclasses), so that orchestrator /

durabletask/internal/type_discovery.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def _input_annotation(fn: Callable[..., Any], position: int,
8888

8989
if annotation is inspect.Parameter.empty or annotation is Any:
9090
return None
91-
return annotation if _resolve_converter(converter).is_reconstructable(annotation) else None
91+
return annotation if _resolve_converter(converter).can_reconstruct(annotation) else None
9292

9393

9494
def orchestrator_input_type(fn: Callable[..., Any],
@@ -129,7 +129,7 @@ def activity_output_type(fn: Any, converter: DataConverter | None = None) -> Any
129129

130130
if annotation is inspect.Signature.empty or annotation is Any or annotation is None:
131131
return None
132-
return annotation if _resolve_converter(converter).is_reconstructable(annotation) else None
132+
return annotation if _resolve_converter(converter).can_reconstruct(annotation) else None
133133

134134

135135
def entity_input_type(fn: Any, operation: str,

durabletask/serialization.py

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

104-
def is_reconstructable(self, target_type: Any) -> bool:
104+
def can_reconstruct(self, target_type: Any) -> bool:
105105
"""Return True if this converter can rebuild ``target_type`` from a payload.
106106
107107
Inbound type-discovery calls this to decide whether a function's
108108
annotated *input* type (or an activity's *return* annotation) should be
109109
passed to :meth:`deserialize` / :meth:`coerce`. When it returns ``False``
110110
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
111+
gate is what stops the SDK from invoking reconstruction on a type the
112+
converter does not actually handle.
113+
114+
The base implementation is conservative and returns ``False``: a
115+
converter makes no reconstruction claims unless it opts in.
116+
:class:`JsonDataConverter` overrides this to recognize the types its
117+
codec can rebuild (dataclasses and ``from_json()``-capable types, plus
118+
``Optional`` / ``list`` / ``Sequence`` hints wrapping them). Override
119+
this in a custom converter to teach the SDK about its own types (for
120120
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]``).
121+
them are reconstructed instead of arriving as raw JSON.
125122
"""
126-
return _is_reconstructable(self, target_type)
123+
return False
127124

128125

129126
class JsonDataConverter(DataConverter):
@@ -187,6 +184,9 @@ def coerce(self, value: Any, target_type: type | None = None) -> Any:
187184
self._log_coercion_fallback(target_type, e)
188185
return value
189186

187+
def can_reconstruct(self, target_type: Any) -> bool:
188+
return _can_reconstruct(self, target_type)
189+
190190
@staticmethod
191191
def _log_coercion_fallback(target_type: type, error: Exception) -> None:
192192
logger.debug(
@@ -211,24 +211,25 @@ def _log_coercion_fallback(target_type: type, error: Exception) -> None:
211211
# ---------------------------------------------------------------------------
212212

213213

214-
def _is_reconstructable(converter: DataConverter, target_type: Any) -> bool:
215-
"""Default :meth:`DataConverter.is_reconstructable` policy.
214+
def _can_reconstruct(converter: DataConverter, target_type: Any) -> bool:
215+
""":class:`JsonDataConverter`'s reconstruction policy.
216216
217217
Recognizes dataclasses and ``from_json()``-capable types, plus ``Optional``
218218
/ ``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.
219+
annotations are excluded. Recurses through ``converter.can_reconstruct``
220+
(not itself) so a :class:`JsonDataConverter` subclass that overrides
221+
``can_reconstruct`` still participates in the element-type checks of
222+
``Optional`` / ``list`` hints.
222223
"""
223224
origin = typing.get_origin(target_type)
224225
if origin is not None:
225226
args = typing.get_args(target_type)
226227
if origin is typing.Union or origin is types.UnionType:
227228
return any(
228-
converter.is_reconstructable(a) for a in args if a is not type(None)
229+
converter.can_reconstruct(a) for a in args if a is not type(None)
229230
)
230231
if origin in (list, Sequence):
231-
return any(converter.is_reconstructable(a) for a in args)
232+
return any(converter.can_reconstruct(a) for a in args)
232233
return False
233234
if not isinstance(target_type, type):
234235
return False

durabletask/worker.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1420,8 +1420,8 @@ class _RuntimeOrchestrationContext(task.OrchestrationContext):
14201420
def __init__(self,
14211421
instance_id: str,
14221422
registry: _Registry,
1423+
data_converter: DataConverter,
14231424
maximum_timer_interval: timedelta | None = DEFAULT_MAXIMUM_TIMER_INTERVAL,
1424-
data_converter: DataConverter | None = None,
14251425
):
14261426
self._generator = None
14271427
self._is_replaying = True
@@ -1450,7 +1450,7 @@ def __init__(self,
14501450
self._parent_trace_context: pb.TraceContext | None = None
14511451
self._orchestration_trace_context: pb.TraceContext | None = None
14521452
self._maximum_timer_interval = maximum_timer_interval
1453-
self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
1453+
self._data_converter = data_converter
14541454

14551455
def run(self, generator: Generator[task.Task[Any], Any, Any]) -> None:
14561456
self._generator = generator
@@ -2050,13 +2050,13 @@ def __init__(
20502050
self,
20512051
registry: _Registry,
20522052
logger: logging.Logger,
2053+
data_converter: DataConverter,
20532054
persisted_orch_span_id: str | None = None,
20542055
maximum_timer_interval: timedelta | None = DEFAULT_MAXIMUM_TIMER_INTERVAL,
2055-
data_converter: DataConverter | None = None,
20562056
):
20572057
self._registry = registry
20582058
self._logger = logger
2059-
self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
2059+
self._data_converter = data_converter
20602060
self._maximum_timer_interval = maximum_timer_interval
20612061
self._is_suspended = False
20622062
self._suspended_events: list[pb.HistoryEvent] = []
@@ -2834,10 +2834,10 @@ def compare_versions(self, source_version: str | None, default_version: str | No
28342834

28352835
class _ActivityExecutor:
28362836
def __init__(self, registry: _Registry, logger: logging.Logger,
2837-
data_converter: DataConverter | None = None):
2837+
data_converter: DataConverter):
28382838
self._registry = registry
28392839
self._logger = logger
2840-
self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
2840+
self._data_converter = data_converter
28412841

28422842
def execute(
28432843
self,
@@ -2873,10 +2873,10 @@ def execute(
28732873

28742874
class _EntityExecutor:
28752875
def __init__(self, registry: _Registry, logger: logging.Logger,
2876-
data_converter: DataConverter | None = None):
2876+
data_converter: DataConverter):
28772877
self._registry = registry
28782878
self._logger = logger
2879-
self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
2879+
self._data_converter = data_converter
28802880
self._entity_method_cache: dict[tuple[type, str], bool] = {}
28812881

28822882
def execute(

examples/custom_data_converter/README.md

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -51,28 +51,29 @@ everything else** to the default `JsonDataConverter`. This "handle my types,
5151
delegate the rest" shape is the recommended pattern for a real converter — it
5252
costs nothing for non-pydantic payloads.
5353

54-
## Inbound inputs: `is_reconstructable`
54+
## Inbound inputs: `can_reconstruct`
5555

5656
There is one extra detail for reconstructing **inbound** orchestrator/activity
5757
*inputs*. Before the SDK hands an input to your converter, it asks the converter
5858
whether the function's annotated input type is something it can rebuild, via
59-
`DataConverter.is_reconstructable(target_type)`. The default implementation
59+
`DataConverter.can_reconstruct(target_type)`. The default implementation
6060
recognizes dataclasses and `from_json()`-capable types (and `Optional` / `list`
6161
wrappers) — it does **not** know about pydantic models, so without an override an
6262
input annotated `order: Order` would arrive as a plain `dict`.
6363

64-
The converter overrides `is_reconstructable` to also recognize
65-
`pydantic.BaseModel` subclasses:
64+
The converter overrides `can_reconstruct` to also recognize
65+
`pydantic.BaseModel` subclasses, deferring everything else to the same
66+
`JsonDataConverter` fallback it uses for serialization:
6667

6768
```python
68-
def is_reconstructable(self, target_type):
69+
def can_reconstruct(self, target_type):
6970
if _is_model_type(target_type):
7071
return True
71-
return super().is_reconstructable(target_type) # keep the defaults
72+
return self._fallback.can_reconstruct(target_type) # dataclasses, from_json, ...
7273
```
7374

74-
Because the base implementation recurses through `self.is_reconstructable`,
75-
`list[OrderItem]` and `Optional[Order]` are recognized too. Outbound values,
75+
The base `DataConverter.can_reconstruct` is conservative — it returns `False`,
76+
so a converter only claims the types it actually rebuilds. Outbound values,
7677
`return_type=` arguments, and typed client accessors (`state.get_output(Receipt)`)
7778
don't depend on this hook — they pass the type to the converter directly.
7879

examples/custom_data_converter/src/converter.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
2727
It may also override one hook:
2828
29-
* ``is_reconstructable(t)`` -- tells the SDK's inbound type-discovery that an
29+
* ``can_reconstruct(t)`` -- tells the SDK's inbound type-discovery that an
3030
*input* annotated with type ``t`` should be handed to ``deserialize`` /
3131
``coerce`` (rather than passed through as raw JSON). The default recognizes
3232
dataclasses and ``from_json()``-capable types; override it to add your own
@@ -92,15 +92,15 @@ def coerce(self, value: Any, target_type: type | None = None) -> Any:
9292
return target_type.model_validate(value) # type: ignore[union-attr]
9393
return self._fallback.coerce(value, target_type)
9494

95-
def is_reconstructable(self, target_type: Any) -> bool:
95+
def can_reconstruct(self, target_type: Any) -> bool:
9696
# Teach the SDK's inbound type-discovery that pydantic models are
9797
# reconstructable, so an orchestrator/activity input annotated with a
9898
# model type is rebuilt (and validated) by this converter instead of
99-
# arriving as a plain ``dict``. Delegating to ``super()`` keeps the
100-
# default behavior (dataclasses, ``from_json`` types, ``Optional`` /
101-
# ``list`` wrappers, builtins excluded) for everything else; because the
102-
# base recurses through ``self.is_reconstructable``, ``list[OrderItem]``
103-
# and ``Optional[Order]`` are recognized too.
99+
# arriving as a plain ``dict``. For everything else, defer to the same
100+
# ``JsonDataConverter`` fallback this converter uses for serialization,
101+
# so its reconstruction claims match what it actually handles
102+
# (dataclasses, ``from_json`` types, ``Optional`` / ``list`` wrappers;
103+
# builtins excluded).
104104
if _is_model_type(target_type):
105105
return True
106-
return super().is_reconstructable(target_type)
106+
return self._fallback.can_reconstruct(target_type)

examples/custom_data_converter/src/workflows.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
# ---------------------------------------------------------------------------
2828
# These are plain ``pydantic.BaseModel`` subclasses -- no special hooks. The
2929
# custom ``PydanticDataConverter`` both serializes them and (because it
30-
# overrides ``is_reconstructable``) teaches the SDK to reconstruct them for
30+
# overrides ``can_reconstruct``) teaches the SDK to reconstruct them for
3131
# inbound orchestrator/activity inputs.
3232

3333

tests/durabletask/test_activity_executor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any
77

88
from durabletask import task, worker
9+
from durabletask.serialization import JsonDataConverter
910

1011
logging.basicConfig(
1112
format='%(asctime)s.%(msecs)03d %(name)s %(levelname)s: %(message)s',
@@ -53,5 +54,5 @@ def test_activity(ctx: task.ActivityContext, _):
5354
def _get_activity_executor(fn: task.Activity) -> tuple[worker._ActivityExecutor, str]:
5455
registry = worker._Registry()
5556
name = registry.add_activity(fn)
56-
executor = worker._ActivityExecutor(registry, TEST_LOGGER)
57+
executor = worker._ActivityExecutor(registry, TEST_LOGGER, JsonDataConverter())
5758
return executor, name

tests/durabletask/test_entity_executor.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from durabletask import entities
99
from durabletask.internal.entity_state_shim import StateShim
10+
from durabletask.serialization import JsonDataConverter
1011
from durabletask.worker import _EntityExecutor, _Registry
1112

1213

@@ -15,7 +16,7 @@ def _make_executor(*entity_args) -> _EntityExecutor:
1516
registry = _Registry()
1617
for entity in entity_args:
1718
registry.add_entity(entity)
18-
return _EntityExecutor(registry, logging.getLogger("test"))
19+
return _EntityExecutor(registry, logging.getLogger("test"), JsonDataConverter())
1920

2021

2122
def _execute(executor, entity_name, operation, encoded_input=None):

0 commit comments

Comments
 (0)