@@ -25,7 +25,12 @@ def _to_iso(value: datetime | None) -> str | None:
2525
2626
2727def _from_iso (value : str | None ) -> datetime | None :
28- return datetime .fromisoformat (value ) if value else None
28+ if not value :
29+ return None
30+ # .NET serializes ``DateTimeOffset`` with a numeric offset, but tolerate a
31+ # trailing ``Z`` so states written by other producers still parse on the
32+ # Python versions that predate ``fromisoformat`` accepting ``Z``.
33+ return datetime .fromisoformat (value .replace ("Z" , "+00:00" ))
2934
3035
3136def _interval_to_seconds (value : timedelta | None ) -> float | None :
@@ -36,6 +41,95 @@ def _interval_from_seconds(value: float | None) -> timedelta | None:
3641 return timedelta (seconds = value ) if value is not None else None
3742
3843
44+ # Number of 100-nanosecond ticks per second, matching the .NET ``TimeSpan``
45+ # resolution used when formatting the fractional component.
46+ _TICKS_PER_SECOND = 10_000_000
47+
48+
49+ def _interval_to_timespan (value : timedelta | None ) -> str | None :
50+ """Format a ``timedelta`` as a .NET ``TimeSpan`` (``[-][d.]hh:mm:ss[.fffffff]``).
51+
52+ The Durable Task Scheduler dashboard deserializes the schedule interval into
53+ a .NET ``TimeSpan``, whose JSON converter only accepts this constant format.
54+ """
55+ if value is None :
56+ return None
57+ negative = value < timedelta (0 )
58+ value = abs (value )
59+ days = value .days
60+ hours , remainder = divmod (value .seconds , 3600 )
61+ minutes , seconds = divmod (remainder , 60 )
62+ if days :
63+ formatted = f"{ days } .{ hours :02d} :{ minutes :02d} :{ seconds :02d} "
64+ else :
65+ formatted = f"{ hours :02d} :{ minutes :02d} :{ seconds :02d} "
66+ if value .microseconds :
67+ ticks = value .microseconds * 10 # microseconds -> 100-ns ticks
68+ formatted += f".{ ticks :07d} "
69+ return f"-{ formatted } " if negative else formatted
70+
71+
72+ def _interval_from_timespan (value : str ) -> timedelta :
73+ """Parse a .NET ``TimeSpan`` string (``[-][d.]hh:mm:ss[.fffffff]``)."""
74+ text = value .strip ()
75+ negative = text .startswith ("-" )
76+ if negative :
77+ text = text [1 :]
78+
79+ fraction = 0.0
80+ if "." in text :
81+ head , _ , tail = text .rpartition ("." )
82+ # A dot before the first ``:`` is the day separator, not a fraction.
83+ if ":" in tail :
84+ # e.g. ``1.02:03:04`` -- the ``.`` separates days from the clock.
85+ days_part , _ , clock = text .partition ("." )
86+ days = int (days_part )
87+ hours , minutes , seconds = (int (p ) for p in clock .split (":" ))
88+ else :
89+ # ``head`` holds ``[d.]hh:mm:ss`` and ``tail`` the ticks fraction.
90+ fraction = int (tail .ljust (7 , "0" )[:7 ]) / _TICKS_PER_SECOND
91+ days , hours , minutes , seconds = _split_clock (head )
92+ else :
93+ days , hours , minutes , seconds = _split_clock (text )
94+
95+ result = timedelta (days = days , hours = hours , minutes = minutes ,
96+ seconds = seconds ) + timedelta (seconds = fraction )
97+ return - result if negative else result
98+
99+
100+ def _split_clock (text : str ) -> tuple [int , int , int , int ]:
101+ """Split ``[d.]hh:mm:ss`` into ``(days, hours, minutes, seconds)``."""
102+ days = 0
103+ if "." in text :
104+ days_part , _ , text = text .partition ("." )
105+ days = int (days_part )
106+ hours , minutes , seconds = (int (part ) for part in text .split (":" ))
107+ return days , hours , minutes , seconds
108+
109+
110+ def _get (data : dict [str , Any ], * keys : str , default : Any = None ) -> Any :
111+ """Return the first present key from ``data``.
112+
113+ Reads tolerate both the .NET-compatible PascalCase keys and the legacy
114+ snake_case keys written by earlier Python workers.
115+ """
116+ for key in keys :
117+ if key in data :
118+ return data [key ]
119+ return default
120+
121+
122+ def _parse_interval (data : dict [str , Any ]) -> timedelta :
123+ """Read the interval from either the .NET ``Interval`` or legacy field."""
124+ timespan = _get (data , "Interval" , "interval" )
125+ if isinstance (timespan , str ):
126+ return _interval_from_timespan (timespan )
127+ seconds = _get (data , "interval_seconds" )
128+ if seconds is not None :
129+ return timedelta (seconds = seconds )
130+ raise KeyError ("interval" )
131+
132+
39133@dataclass
40134class ScheduleCreationOptions :
41135 """Options for creating a new schedule."""
@@ -230,29 +324,34 @@ def _validate(self):
230324 raise ValueError ("start_at cannot be later than end_at." )
231325
232326 def to_json (self ) -> dict [str , Any ]:
327+ # Serialized with .NET-compatible property names and value shapes so the
328+ # Durable Task Scheduler dashboard can deserialize the raw entity state:
329+ # PascalCase keys and the interval as a .NET ``TimeSpan`` string.
233330 return {
234- "schedule_id " : self .schedule_id ,
235- "orchestration_name " : self .orchestration_name ,
236- "interval_seconds " : self .interval . total_seconds ( ),
237- "orchestration_input " : self .orchestration_input ,
238- "orchestration_instance_id " : self .orchestration_instance_id ,
239- "start_at " : _to_iso (self .start_at ),
240- "end_at " : _to_iso (self .end_at ),
241- "start_immediately_if_late " : self .start_immediately_if_late ,
331+ "ScheduleId " : self .schedule_id ,
332+ "OrchestrationName " : self .orchestration_name ,
333+ "Interval " : _interval_to_timespan ( self .interval ),
334+ "OrchestrationInput " : self .orchestration_input ,
335+ "OrchestrationInstanceId " : self .orchestration_instance_id ,
336+ "StartAt " : _to_iso (self .start_at ),
337+ "EndAt " : _to_iso (self .end_at ),
338+ "StartImmediatelyIfLate " : self .start_immediately_if_late ,
242339 }
243340
244341 @classmethod
245342 def from_json (cls , data : dict [str , Any ]) -> "ScheduleConfiguration" :
246343 config = cls (
247- data [ " schedule_id"] ,
248- data [ " orchestration_name"] ,
249- timedelta ( seconds = data [ "interval_seconds" ] ),
344+ _get ( data , "ScheduleId" , " schedule_id") ,
345+ _get ( data , "OrchestrationName" , " orchestration_name") ,
346+ _parse_interval ( data ),
250347 )
251- config .orchestration_input = data .get ("orchestration_input" )
252- config .orchestration_instance_id = data .get ("orchestration_instance_id" )
253- config .start_at = _from_iso (data .get ("start_at" ))
254- config .end_at = _from_iso (data .get ("end_at" ))
255- config .start_immediately_if_late = bool (data .get ("start_immediately_if_late" , False ))
348+ config .orchestration_input = _get (data , "OrchestrationInput" , "orchestration_input" )
349+ config .orchestration_instance_id = _get (
350+ data , "OrchestrationInstanceId" , "orchestration_instance_id" )
351+ config .start_at = _from_iso (_get (data , "StartAt" , "start_at" ))
352+ config .end_at = _from_iso (_get (data , "EndAt" , "end_at" ))
353+ config .start_immediately_if_late = bool (
354+ _get (data , "StartImmediatelyIfLate" , "start_immediately_if_late" , default = False ))
256355 return config
257356
258357
@@ -273,16 +372,18 @@ def refresh_execution_token(self):
273372
274373 def to_json (self ) -> dict [str , Any ]:
275374 # ``schedule_configuration`` is returned as the object itself; the
276- # serializer recurses into it and fires its own ``to_json`` hook. Only
277- # this type's non-JSON-native leaves (datetimes) are converted here.
375+ # serializer recurses into it and fires its own ``to_json`` hook. Keys
376+ # and value shapes mirror the .NET ``ScheduleState`` so the Durable Task
377+ # Scheduler dashboard can deserialize the raw entity state: PascalCase
378+ # names, the status as its numeric ordinal, and datetimes as ISO strings.
278379 return {
279- "status " : self .status .value ,
280- "execution_token " : self .execution_token ,
281- "last_run_at " : _to_iso (self .last_run_at ),
282- "next_run_at " : _to_iso (self .next_run_at ),
283- "schedule_created_at " : _to_iso (self .schedule_created_at ),
284- "schedule_last_modified_at " : _to_iso (self .schedule_last_modified_at ),
285- "schedule_configuration " : self .schedule_configuration ,
380+ "Status " : self .status .to_dotnet_ordinal () ,
381+ "ExecutionToken " : self .execution_token ,
382+ "LastRunAt " : _to_iso (self .last_run_at ),
383+ "NextRunAt " : _to_iso (self .next_run_at ),
384+ "ScheduleCreatedAt " : _to_iso (self .schedule_created_at ),
385+ "ScheduleLastModifiedAt " : _to_iso (self .schedule_last_modified_at ),
386+ "ScheduleConfiguration " : self .schedule_configuration ,
286387 }
287388
288389 @classmethod
@@ -291,15 +392,17 @@ def from_json(cls, data: dict[str, Any]) -> "ScheduleState":
291392 # ``from_json`` hook directly. ``ScheduleConfiguration`` is an internal
292393 # type, so there is no need to route it through a (possibly custom)
293394 # converter -- keeping this hook converter-free means it round-trips
294- # under any code path, not only the worker's threaded converter.
395+ # under any code path, not only the worker's threaded converter. Reads
396+ # accept both the .NET-compatible and legacy snake_case shapes.
295397 state = cls ()
296- state .status = ScheduleStatus (data ["status" ])
297- state .execution_token = data ["execution_token" ]
298- state .last_run_at = _from_iso (data .get ("last_run_at" ))
299- state .next_run_at = _from_iso (data .get ("next_run_at" ))
300- state .schedule_created_at = _from_iso (data .get ("schedule_created_at" ))
301- state .schedule_last_modified_at = _from_iso (data .get ("schedule_last_modified_at" ))
302- config_data = data .get ("schedule_configuration" )
398+ state .status = ScheduleStatus .from_dotnet (_get (data , "Status" , "status" ))
399+ state .execution_token = _get (data , "ExecutionToken" , "execution_token" )
400+ state .last_run_at = _from_iso (_get (data , "LastRunAt" , "last_run_at" ))
401+ state .next_run_at = _from_iso (_get (data , "NextRunAt" , "next_run_at" ))
402+ state .schedule_created_at = _from_iso (_get (data , "ScheduleCreatedAt" , "schedule_created_at" ))
403+ state .schedule_last_modified_at = _from_iso (
404+ _get (data , "ScheduleLastModifiedAt" , "schedule_last_modified_at" ))
405+ config_data = _get (data , "ScheduleConfiguration" , "schedule_configuration" )
303406 state .schedule_configuration = (
304407 ScheduleConfiguration .from_json (config_data ) if config_data is not None else None )
305408 return state
0 commit comments