88
99from durabletask .internal .helpers import ensure_aware
1010from durabletask .scheduled .schedule_status import ScheduleStatus
11- from durabletask .serialization import JsonDataConverter
11+ from durabletask .serialization import DataConverter , JsonDataConverter
1212
1313MINIMUM_INTERVAL = timedelta (seconds = 1 )
1414
@@ -425,13 +425,23 @@ def to_json(self) -> dict[str, Any]:
425425 }
426426
427427 @classmethod
428- def from_json (cls , data : dict [str , Any ]) -> "ScheduleState" :
429- # The nested configuration is reconstructed by calling its own
430- # ``from_json`` hook directly. ``ScheduleConfiguration`` is an internal
431- # type, so there is no need to route it through a (possibly custom)
432- # converter -- keeping this hook converter-free means it round-trips
433- # under any code path, not only the worker's threaded converter. Reads
434- # accept both the .NET-compatible and legacy snake_case shapes.
428+ def from_json (cls , data : dict [str , Any ],
429+ converter : DataConverter | None = None ) -> "ScheduleState" :
430+ # Reads accept both the .NET-compatible and legacy snake_case shapes.
431+ #
432+ # The nested ``ScheduleConfiguration`` must round-trip under any
433+ # conforming converter. Converters differ in how they hand nested
434+ # custom objects to this hook:
435+ # * The default JSON converter leaves nested values as plain dicts and
436+ # expects the parent hook to rebuild them; it passes itself as
437+ # ``converter`` (a hook that declares the parameter opts in) so the
438+ # reconstruction can defer to ``converter.coerce``.
439+ # * The Azure Functions ``df`` codec rebuilds nested ``to_json`` /
440+ # ``from_json`` envelopes bottom-up, so it invokes this hook with the
441+ # configuration *already* reconstructed (and without a converter).
442+ # Handle both: skip reconstruction when it is already a
443+ # ``ScheduleConfiguration``, otherwise route a raw dict through the
444+ # converter when one was supplied, falling back to the hook directly.
435445 state = cls ()
436446 state .status = ScheduleStatus .from_dotnet (_get (data , "Status" , "status" ))
437447 # Preserve the token generated by ``__init__`` when the field is absent;
@@ -446,8 +456,7 @@ def from_json(cls, data: dict[str, Any]) -> "ScheduleState":
446456 state .schedule_last_modified_at = _from_iso (
447457 _get (data , "ScheduleLastModifiedAt" , "schedule_last_modified_at" ))
448458 config_data = _get (data , "ScheduleConfiguration" , "schedule_configuration" )
449- state .schedule_configuration = (
450- ScheduleConfiguration .from_json (config_data ) if config_data is not None else None )
459+ state .schedule_configuration = _rebuild_configuration (config_data , converter )
451460 return state
452461
453462 def to_description (self ) -> ScheduleDescription :
@@ -470,3 +479,21 @@ def to_description(self) -> ScheduleDescription:
470479
471480def _new_token () -> str :
472481 return uuid .uuid4 ().hex
482+
483+
484+ def _rebuild_configuration (
485+ config_data : Any , converter : DataConverter | None ) -> "ScheduleConfiguration | None" :
486+ """Reconstruct a nested ``ScheduleConfiguration`` for any converter.
487+
488+ ``config_data`` may be ``None``, an already-reconstructed
489+ ``ScheduleConfiguration`` (codecs that rebuild nested envelopes bottom-up,
490+ e.g. the Azure Functions ``df`` codec), or a plain mapping (the default JSON
491+ converter, which leaves nested values as dicts). When a ``converter`` is
492+ supplied its ``coerce`` drives reconstruction; otherwise the hook is called
493+ directly.
494+ """
495+ if config_data is None or isinstance (config_data , ScheduleConfiguration ):
496+ return config_data
497+ if converter is not None :
498+ return converter .coerce (config_data , ScheduleConfiguration )
499+ return ScheduleConfiguration .from_json (config_data )
0 commit comments