Skip to content

Commit 2dbff23

Browse files
committed
Serialization improvements (WIP)
1 parent 4df10bc commit 2dbff23

10 files changed

Lines changed: 252 additions & 74 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ ADDED
2727
custom status, entity state) routes through it. The default
2828
`JsonDataConverter` preserves existing behavior, so a custom converter (for
2929
example one backed by pydantic) is opt-in. Custom objects can opt in via a
30-
`to_json()` hook and a `from_json(value)` classmethod.
30+
`to_json()` hook and a `from_json(value)` classmethod. Objects that contain
31+
other hook-using objects round-trip automatically: nested `to_json()` hooks
32+
fire at any depth during serialization, and a `from_json` hook may declare an
33+
optional second parameter (`from_json(cls, value, converter)`) to reconstruct
34+
nested typed values via `converter.coerce(child, ChildType)` instead of by
35+
hand.
3136
- `OrchestrationContext.call_activity`, `call_sub_orchestrator`, and
3237
`call_entity` accept an optional `return_type`, and `wait_for_external_event`
3338
accepts an optional `data_type`. When provided, the result/event payload is

durabletask/internal/json_codec.py

Lines changed: 75 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from __future__ import annotations
1515

1616
import dataclasses
17+
import inspect
1718
import json
1819
import types
1920
import typing
@@ -47,7 +48,8 @@ def to_json(obj: Any) -> str:
4748
) from e
4849

4950

50-
def from_json(json_str: str | bytes | bytearray, expected_type: type | None = None) -> Any:
51+
def from_json(json_str: str | bytes | bytearray, expected_type: type | None = None,
52+
converter: Any | None = None) -> Any:
5153
"""Deserialize a JSON string, optionally coercing the result to a type.
5254
5355
When ``expected_type`` is ``None`` (the default) the raw parsed JSON is
@@ -62,11 +64,15 @@ def from_json(json_str: str | bytes | bytearray, expected_type: type | None = No
6264
classmethod are reconstructed via that hook, and ``Optional``/``Union`` and
6365
``list`` type hints are honored recursively. The destination type is always
6466
supplied by the caller; it is never read from the payload.
67+
68+
``converter`` is the active :class:`~durabletask.serialization.DataConverter`.
69+
It is forwarded to ``from_json`` hooks that opt in (see :func:`coerce_to_type`)
70+
so they can reconstruct nested typed values via ``converter.coerce(...)``.
6571
"""
6672
if expected_type is None:
6773
return json.loads(json_str, object_hook=_legacy_object_hook)
6874
raw = json.loads(json_str, object_hook=_strip_legacy_marker)
69-
return coerce_to_type(raw, expected_type)
75+
return coerce_to_type(raw, expected_type, converter)
7076

7177

7278
def _encode_custom_object(o: Any) -> Any:
@@ -92,7 +98,13 @@ def _encode_custom_object(o: Any) -> Any:
9298
if callable(to_json_hook):
9399
return to_json_hook(o)
94100
if dataclasses.is_dataclass(o) and not isinstance(o, type):
95-
return dataclasses.asdict(o)
101+
# Shallow-convert to a dict whose *values are the original field objects*
102+
# (unlike ``dataclasses.asdict``, which deep-recurses and would convert a
103+
# nested dataclass via ``asdict`` -- bypassing that child's ``to_json``
104+
# hook). ``json.dumps`` then recurses into each value and re-enters this
105+
# hook for any nested custom object, so nested ``to_json`` hooks fire at
106+
# every depth (including inside lists/dicts).
107+
return {f.name: getattr(o, f.name) for f in dataclasses.fields(o)}
96108
if isinstance(o, SimpleNamespace):
97109
return vars(o)
98110
# This will raise a TypeError describing the unsupported type.
@@ -112,20 +124,31 @@ def _strip_legacy_marker(d: dict[str, Any]) -> dict[str, Any]:
112124
return d
113125

114126

115-
def coerce_to_type(value: Any, expected_type: Any) -> Any:
127+
def coerce_to_type(value: Any, expected_type: Any, converter: Any | None = None) -> Any:
116128
"""Coerce an already-parsed JSON value to ``expected_type``.
117129
118130
Handles ``None``/``Optional``/``Union`` and ``list`` type hints recursively,
119131
types exposing a ``from_json()`` classmethod, and dataclasses (including
120132
nested dataclass fields). The destination type is always caller-supplied and
121133
never derived from the payload, keeping deserialization secure.
134+
135+
``converter`` is the active :class:`~durabletask.serialization.DataConverter`.
136+
A ``from_json`` hook may opt in to receiving it (by accepting a second
137+
positional parameter) and delegate nested reconstruction back to the
138+
converter, e.g. ``converter.coerce(child, ChildType)``. This keeps hooks free
139+
of manual nested deserialization and routes children through the same policy.
122140
"""
123141
if expected_type is None or value is None:
124142
return value
125143

144+
# ``Any`` imposes no constraint -- and ``isinstance(x, Any)`` raises -- so
145+
# short-circuit before any type inspection below.
146+
if expected_type is typing.Any:
147+
return value
148+
126149
origin = typing.get_origin(expected_type)
127150
if origin is not None:
128-
return _coerce_generic(value, expected_type, origin)
151+
return _coerce_generic(value, expected_type, origin, converter)
129152

130153
if not isinstance(expected_type, type):
131154
# Not a concrete, instantiable type (e.g. a typing special form we don't
@@ -137,10 +160,10 @@ def coerce_to_type(value: Any, expected_type: Any) -> Any:
137160

138161
from_json_hook = getattr(expected_type, "from_json", None)
139162
if callable(from_json_hook):
140-
return from_json_hook(value)
163+
return _invoke_from_json(from_json_hook, value, converter)
141164

142165
if dataclasses.is_dataclass(expected_type) and isinstance(value, dict):
143-
return _build_dataclass(expected_type, cast(dict[str, Any], value))
166+
return _build_dataclass(expected_type, cast(dict[str, Any], value), converter)
144167

145168
type_ctor = cast(Any, expected_type)
146169
try:
@@ -153,29 +176,69 @@ def coerce_to_type(value: Any, expected_type: Any) -> Any:
153176
) from e
154177

155178

156-
def _coerce_generic(value: Any, expected_type: Any, origin: Any) -> Any:
179+
def _invoke_from_json(from_json_hook: Any, value: Any, converter: Any | None) -> Any:
180+
"""Invoke a ``from_json`` hook, passing the converter if the hook accepts it.
181+
182+
Hooks may be declared as ``from_json(cls, value)`` (the original contract) or
183+
``from_json(cls, value, converter)`` to opt into managed nested
184+
reconstruction. Arity is detected from the bound hook's signature, so the
185+
extra parameter is fully backwards compatible. When a converter-aware hook is
186+
found but no converter was threaded (e.g. a direct ``json_codec`` call), the
187+
shared default converter is resolved lazily so the hook always receives one.
188+
"""
189+
wants_converter = False
190+
try:
191+
params = [
192+
p for p in inspect.signature(from_json_hook).parameters.values()
193+
if p.kind in (inspect.Parameter.POSITIONAL_ONLY,
194+
inspect.Parameter.POSITIONAL_OR_KEYWORD)
195+
]
196+
wants_converter = len(params) >= 2
197+
except (TypeError, ValueError):
198+
wants_converter = False
199+
200+
if wants_converter:
201+
if converter is None:
202+
converter = _default_converter()
203+
return from_json_hook(value, converter)
204+
return from_json_hook(value)
205+
206+
207+
def _default_converter() -> Any:
208+
# Lazy import to avoid a load-time cycle: ``serialization`` imports this
209+
# module at import time, but by the time a hook actually runs both modules
210+
# are fully initialized.
211+
from durabletask.serialization import DEFAULT_DATA_CONVERTER
212+
return DEFAULT_DATA_CONVERTER
213+
214+
215+
def _coerce_generic(value: Any, expected_type: Any, origin: Any, converter: Any | None = None) -> Any:
157216
args = typing.get_args(expected_type)
158217
if origin is typing.Union or origin is types.UnionType:
159218
# If the value already matches a member type, keep it as-is.
160219
non_none = [a for a in args if a is not type(None)]
161220
for arg in non_none:
221+
# An ``Any`` member imposes no constraint (and ``isinstance(x, Any)``
222+
# raises), so the value is acceptable as-is.
223+
if arg is typing.Any:
224+
return value
162225
if isinstance(arg, type) and isinstance(value, arg):
163226
return value
164227
# ``Optional[T]`` (exactly one non-None member): coerce to that member.
165228
# For a genuine multi-member ``Union`` where the value matched none of
166229
# the members, leave it untouched rather than guessing the first arg --
167230
# forcing a coercion there can silently mis-construct the wrong type.
168231
if len(non_none) == 1:
169-
return coerce_to_type(value, non_none[0])
232+
return coerce_to_type(value, non_none[0], converter)
170233
return value
171234
if origin in (list, Sequence) and isinstance(value, list):
172235
elem_type = args[0] if args else None
173-
return [coerce_to_type(item, elem_type) for item in cast(list[Any], value)]
236+
return [coerce_to_type(item, elem_type, converter) for item in cast(list[Any], value)]
174237
# Other generics (dict, tuple, ...) are returned as parsed JSON.
175238
return value
176239

177240

178-
def _build_dataclass(cls: Any, data: dict[str, Any]) -> Any:
241+
def _build_dataclass(cls: Any, data: dict[str, Any], converter: Any | None = None) -> Any:
179242
"""Construct a dataclass from its dict payload, recursing into typed fields."""
180243
try:
181244
hints = typing.get_type_hints(cls)
@@ -186,5 +249,5 @@ def _build_dataclass(cls: Any, data: dict[str, Any]) -> Any:
186249
if field.name not in data:
187250
continue
188251
field_type = hints.get(field.name)
189-
kwargs[field.name] = coerce_to_type(data[field.name], field_type)
252+
kwargs[field.name] = coerce_to_type(data[field.name], field_type, converter)
190253
return cls(**kwargs)

durabletask/scheduled/client.py

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,10 @@
22
# Licensed under the MIT License.
33

44
import logging
5-
from dataclasses import asdict
6-
from typing import Any
75

86
from durabletask.client import (EntityQuery, OrchestrationStatus,
97
TaskHubGrpcClient)
108
from durabletask.entities import EntityInstanceId
11-
from durabletask.internal import shared
129
from durabletask.scheduled import transitions
1310
from durabletask.scheduled.exceptions import ScheduleNotFoundError
1411
from durabletask.scheduled.models import (ScheduleCreationOptions,
@@ -22,20 +19,6 @@
2219
logger = logging.getLogger("durabletask.scheduled")
2320

2421

25-
def _parse_state(serialized_state: Any) -> ScheduleState | None:
26-
if serialized_state is None:
27-
return None
28-
data = serialized_state
29-
if isinstance(data, str):
30-
if not data.strip():
31-
# A deleted (or never-initialized) entity reports empty state.
32-
return None
33-
data = shared.from_json(data)
34-
if isinstance(data, dict):
35-
return ScheduleState.from_dict(data)
36-
return None
37-
38-
3922
class ScheduleClient:
4023
"""Client for managing a single schedule instance."""
4124

@@ -53,14 +36,14 @@ def schedule_id(self) -> str:
5336
"""Gets the ID of this schedule."""
5437
return self._schedule_id
5538

56-
def _run_operation(self, operation_name: str, input: Any | None = None) -> None:
39+
def _run_operation(self, operation_name: str, input: object | None = None) -> None:
5740
request = ScheduleOperationRequest(
5841
entity_id=str(self._entity_id),
5942
operation_name=operation_name,
6043
input=input,
6144
)
6245
instance_id = self._client.schedule_new_orchestration(
63-
execute_schedule_operation_orchestrator, input=asdict(request))
46+
execute_schedule_operation_orchestrator, input=request)
6447
state = self._client.wait_for_orchestration_completion(
6548
instance_id, timeout=self._operation_timeout)
6649
if state is None or state.runtime_status != OrchestrationStatus.COMPLETED:
@@ -71,11 +54,11 @@ def _run_operation(self, operation_name: str, input: Any | None = None) -> None:
7154

7255
def create(self, options: ScheduleCreationOptions) -> None:
7356
"""Create or update this schedule with the given configuration."""
74-
self._run_operation(transitions.CREATE_SCHEDULE, options.to_json())
57+
self._run_operation(transitions.CREATE_SCHEDULE, options)
7558

7659
def update(self, options: ScheduleUpdateOptions) -> None:
7760
"""Update this schedule's configuration."""
78-
self._run_operation(transitions.UPDATE_SCHEDULE, options.to_json())
61+
self._run_operation(transitions.UPDATE_SCHEDULE, options)
7962

8063
def pause(self) -> None:
8164
"""Pause this schedule."""
@@ -94,7 +77,7 @@ def describe(self) -> ScheduleDescription:
9477
metadata = self._client.get_entity(self._entity_id, include_state=True)
9578
if metadata is None:
9679
raise ScheduleNotFoundError(self._schedule_id)
97-
state = _parse_state(metadata.get_state())
80+
state = metadata.get_typed_state(ScheduleState)
9881
if state is None:
9982
raise ScheduleNotFoundError(self._schedule_id)
10083
return state.to_description()
@@ -136,7 +119,7 @@ def list_schedules(self, filter: ScheduleQuery | None = None) -> list[ScheduleDe
136119
)
137120
results: list[ScheduleDescription] = []
138121
for metadata in self._client.get_all_entities(query):
139-
state = _parse_state(metadata.get_state())
122+
state = metadata.get_typed_state(ScheduleState)
140123
if state is None or state.schedule_configuration is None:
141124
continue
142125
if not self._matches_filter(state, filter):

durabletask/scheduled/models.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ def _validate(self):
220220
if self.start_at is not None and self.end_at is not None and self.start_at > self.end_at:
221221
raise ValueError("start_at cannot be later than end_at.")
222222

223-
def to_dict(self) -> dict[str, Any]:
223+
def to_json(self) -> dict[str, Any]:
224224
return {
225225
"schedule_id": self.schedule_id,
226226
"orchestration_name": self.orchestration_name,
@@ -232,9 +232,9 @@ def to_dict(self) -> dict[str, Any]:
232232
"start_immediately_if_late": self.start_immediately_if_late,
233233
}
234234

235-
@staticmethod
236-
def from_dict(data: dict[str, Any]) -> "ScheduleConfiguration":
237-
config = ScheduleConfiguration(
235+
@classmethod
236+
def from_json(cls, data: dict[str, Any]) -> "ScheduleConfiguration":
237+
config = cls(
238238
data["schedule_id"],
239239
data["orchestration_name"],
240240
timedelta(seconds=data["interval_seconds"]),
@@ -262,29 +262,35 @@ def __init__(self):
262262
def refresh_execution_token(self):
263263
self.execution_token = _new_token()
264264

265-
def to_dict(self) -> dict[str, Any]:
265+
def to_json(self) -> dict[str, Any]:
266+
# ``schedule_configuration`` is returned as the object itself; the
267+
# serializer recurses into it and fires its own ``to_json`` hook. Only
268+
# this type's non-JSON-native leaves (datetimes) are converted here.
266269
return {
267270
"status": self.status.value,
268271
"execution_token": self.execution_token,
269272
"last_run_at": _to_iso(self.last_run_at),
270273
"next_run_at": _to_iso(self.next_run_at),
271274
"schedule_created_at": _to_iso(self.schedule_created_at),
272275
"schedule_last_modified_at": _to_iso(self.schedule_last_modified_at),
273-
"schedule_configuration":
274-
self.schedule_configuration.to_dict() if self.schedule_configuration else None,
276+
"schedule_configuration": self.schedule_configuration,
275277
}
276278

277-
@staticmethod
278-
def from_dict(data: dict[str, Any]) -> "ScheduleState":
279-
state = ScheduleState()
279+
@classmethod
280+
def from_json(cls, data: dict[str, Any], converter: Any) -> "ScheduleState":
281+
# The nested configuration is reconstructed through the converter, which
282+
# routes it to ``ScheduleConfiguration``'s own ``from_json`` hook (and
283+
# honors a custom converter). Only this type's datetime leaves are
284+
# rebuilt by hand.
285+
state = cls()
280286
state.status = ScheduleStatus(data["status"])
281287
state.execution_token = data["execution_token"]
282288
state.last_run_at = _from_iso(data.get("last_run_at"))
283289
state.next_run_at = _from_iso(data.get("next_run_at"))
284290
state.schedule_created_at = _from_iso(data.get("schedule_created_at"))
285291
state.schedule_last_modified_at = _from_iso(data.get("schedule_last_modified_at"))
286-
config_data = data.get("schedule_configuration")
287-
state.schedule_configuration = ScheduleConfiguration.from_dict(config_data) if config_data else None
292+
state.schedule_configuration = converter.coerce(
293+
data.get("schedule_configuration"), ScheduleConfiguration)
288294
return state
289295

290296
def to_description(self) -> ScheduleDescription:

durabletask/scheduled/orchestrator.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
from collections.abc import Generator
55
from dataclasses import dataclass
6-
from types import SimpleNamespace
76
from typing import Any
87

98
from durabletask import task
@@ -12,25 +11,27 @@
1211

1312
@dataclass
1413
class ScheduleOperationRequest:
15-
"""Request describing an operation to execute against a schedule entity."""
14+
"""Request describing an operation to execute against a schedule entity.
15+
16+
A plain dataclass: the serializer round-trips it (and its ``input`` payload)
17+
automatically. ``input`` stays an ``Any`` here -- it is reconstructed into the
18+
concrete options type at the entity-method boundary from that method's
19+
parameter annotation.
20+
"""
1621

1722
entity_id: str
1823
operation_name: str
1924
input: Any | None = None
2025

2126

2227
def execute_schedule_operation_orchestrator(
23-
ctx: task.OrchestrationContext, request: Any) -> Generator[task.Task[Any], Any, Any]:
28+
ctx: task.OrchestrationContext,
29+
request: ScheduleOperationRequest) -> Generator[task.Task[Any], Any, Any]:
2430
"""Orchestrator that executes a single operation on a schedule entity.
2531
2632
Client-side write operations route through this orchestrator so callers can await
2733
completion (and surface failures) of the underlying entity operation.
2834
"""
29-
if isinstance(request, SimpleNamespace):
30-
request = vars(request)
31-
if isinstance(request, dict):
32-
request = ScheduleOperationRequest(**request)
33-
3435
entity_id = EntityInstanceId.parse(request.entity_id)
3536
result = yield ctx.call_entity(entity_id, request.operation_name, request.input)
3637
return result

0 commit comments

Comments
 (0)