durabletask 1.7.0__tar.gz → 1.7.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. {durabletask-1.7.0 → durabletask-1.7.1}/PKG-INFO +1 -1
  2. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/helpers.py +54 -0
  3. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/payload/store.py +3 -3
  4. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/models.py +180 -34
  5. durabletask-1.7.1/durabletask/scheduled/schedule_status.py +66 -0
  6. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/task.py +16 -0
  7. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/worker.py +51 -13
  8. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/PKG-INFO +1 -1
  9. {durabletask-1.7.0 → durabletask-1.7.1}/pyproject.toml +1 -1
  10. durabletask-1.7.0/durabletask/scheduled/schedule_status.py +0 -17
  11. {durabletask-1.7.0 → durabletask-1.7.1}/LICENSE +0 -0
  12. {durabletask-1.7.0 → durabletask-1.7.1}/README.md +0 -0
  13. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/__init__.py +0 -0
  14. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/client.py +0 -0
  15. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/__init__.py +0 -0
  16. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/durable_entity.py +0 -0
  17. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_context.py +0 -0
  18. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_instance_id.py +0 -0
  19. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_lock.py +0 -0
  20. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_metadata.py +0 -0
  21. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/entities/entity_operation_failed_exception.py +0 -0
  22. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/__init__.py +0 -0
  23. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/azure_blob_payloads/__init__.py +0 -0
  24. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +0 -0
  25. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/azure_blob_payloads/options.py +0 -0
  26. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/__init__.py +0 -0
  27. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/_constants.py +0 -0
  28. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/_internal.py +0 -0
  29. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/_logging.py +0 -0
  30. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/activities.py +0 -0
  31. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/azure_blob.py +0 -0
  32. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/client.py +0 -0
  33. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/entity.py +0 -0
  34. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/exceptions.py +0 -0
  35. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/models.py +0 -0
  36. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/orchestrator.py +0 -0
  37. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/serialization.py +0 -0
  38. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/transitions.py +0 -0
  39. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/extensions/history_export/writer.py +0 -0
  40. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/grpc_options.py +0 -0
  41. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/history.py +0 -0
  42. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/client_helpers.py +0 -0
  43. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/entity_state_shim.py +0 -0
  44. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/exceptions.py +0 -0
  45. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/grpc_interceptor.py +0 -0
  46. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/grpc_resiliency.py +0 -0
  47. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/history_helpers.py +0 -0
  48. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/json_encode_output_exception.py +0 -0
  49. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestration_entity_context.py +0 -0
  50. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestrator_service_pb2.py +0 -0
  51. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestrator_service_pb2.pyi +0 -0
  52. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
  53. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +0 -0
  54. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/shared.py +0 -0
  55. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/tracing.py +0 -0
  56. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/internal/type_discovery.py +0 -0
  57. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/payload/__init__.py +0 -0
  58. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/payload/helpers.py +0 -0
  59. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/py.typed +0 -0
  60. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/__init__.py +0 -0
  61. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/client.py +0 -0
  62. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/exceptions.py +0 -0
  63. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/orchestrator.py +0 -0
  64. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/schedule_entity.py +0 -0
  65. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/scheduled/transitions.py +0 -0
  66. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/serialization.py +0 -0
  67. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/testing/__init__.py +0 -0
  68. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask/testing/in_memory_backend.py +0 -0
  69. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/SOURCES.txt +0 -0
  70. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/dependency_links.txt +0 -0
  71. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/requires.txt +0 -0
  72. {durabletask-1.7.0 → durabletask-1.7.1}/durabletask.egg-info/top_level.txt +0 -0
  73. {durabletask-1.7.0 → durabletask-1.7.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask
3
- Version: 1.7.0
3
+ Version: 1.7.1
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -3,6 +3,7 @@
3
3
 
4
4
  import traceback
5
5
  from datetime import datetime, timezone
6
+ from typing import Any, cast
6
7
 
7
8
  from google.protobuf import timestamp_pb2, wrappers_pb2
8
9
 
@@ -136,6 +137,59 @@ def new_failure_details(ex: Exception, _visited: set[int] | None = None) -> pb.T
136
137
  )
137
138
 
138
139
 
140
+ def _failure_details_from_core_dict(fd: dict[str, Any]) -> pb.TaskFailureDetails:
141
+ """Convert a serialized DurableTask.Core ``FailureDetails`` dict to protobuf."""
142
+ inner = fd.get("InnerFailure")
143
+ stack_trace = fd.get("StackTrace")
144
+ return pb.TaskFailureDetails(
145
+ errorType=str(fd.get("ErrorType") or ""),
146
+ errorMessage=str(fd.get("ErrorMessage") or ""),
147
+ stackTrace=get_string_value(str(stack_trace) if stack_trace is not None else None),
148
+ innerFailure=_failure_details_from_core_dict(cast(dict[str, Any], inner)) if isinstance(inner, dict) else None,
149
+ isNonRetriable=bool(fd.get("IsNonRetriable", False)),
150
+ )
151
+
152
+
153
+ def entity_response_failure_details(
154
+ response: dict[str, Any],
155
+ error_content: Any = None) -> pb.TaskFailureDetails:
156
+ """Build failure details from a failed legacy-protocol entity ``ResponseMessage``.
157
+
158
+ Call this only for responses that :func:`is_entity_error_response` reports as
159
+ failures. In the WebJobs "old protocol" ``ResponseMessage`` (see
160
+ ``EntityScheduler/ResponseMessage.cs``), a failed operation serializes the
161
+ human-readable content into ``result`` while ``exceptionType`` carries only
162
+ the exception's type name (a presence marker) -- it is *not* the message.
163
+ This mirrors ``azure-functions-durable-python`` / ``-js``, which read the
164
+ message from ``result`` and ignore ``exceptionType``'s value.
165
+
166
+ Parameters
167
+ ----------
168
+ response:
169
+ The deserialized ``ResponseMessage`` dict.
170
+ error_content:
171
+ The already-deserialized ``result`` payload, used as the failure
172
+ message. A structured ``failureDetails`` object, if present, takes
173
+ precedence (current-protocol shape).
174
+ """
175
+ failure_details = response.get("failureDetails")
176
+ if isinstance(failure_details, dict):
177
+ return _failure_details_from_core_dict(cast(dict[str, Any], failure_details))
178
+ error_type = str(response.get("exceptionType") or "")
179
+ error_message = "" if error_content is None else str(error_content)
180
+ return pb.TaskFailureDetails(errorType=error_type, errorMessage=error_message)
181
+
182
+
183
+ def is_entity_error_response(response: dict[str, Any]) -> bool:
184
+ """Return ``True`` if a legacy-protocol entity ``ResponseMessage`` is a failure.
185
+
186
+ In the WebJobs "old protocol" a failed operation is marked by the presence of
187
+ an ``exceptionType`` field (successful responses omit it). Current-protocol
188
+ payloads may instead carry a structured ``failureDetails`` object.
189
+ """
190
+ return "exceptionType" in response or isinstance(response.get("failureDetails"), dict)
191
+
192
+
139
193
  def new_event_sent_event(event_id: int, instance_id: str, input: str):
140
194
  return pb.HistoryEvent(
141
195
  eventId=event_id,
@@ -21,15 +21,15 @@ class LargePayloadStorageOptions:
21
21
 
22
22
  Attributes:
23
23
  threshold_bytes: Payloads larger than this value (in bytes) will
24
- be externalized to the payload store. Defaults to 900,000
25
- (900 KB), matching the .NET SDK default.
24
+ be externalized to the payload store. Defaults to 262,144
25
+ (256 KiB), matching the .NET SDK default.
26
26
  max_stored_payload_bytes: Maximum payload size (in bytes) that
27
27
  can be stored externally. Payloads exceeding this limit
28
28
  will cause an error. Defaults to 10,485,760 (10 MB).
29
29
  enable_compression: When ``True`` (the default), payloads are
30
30
  GZip-compressed before uploading.
31
31
  """
32
- threshold_bytes: int = 900_000
32
+ threshold_bytes: int = 262_144
33
33
  max_stored_payload_bytes: int = 10 * 1024 * 1024 # 10 MB
34
34
  enable_compression: bool = True
35
35
 
@@ -8,9 +8,15 @@ from typing import Any
8
8
 
9
9
  from durabletask.internal.helpers import ensure_aware
10
10
  from durabletask.scheduled.schedule_status import ScheduleStatus
11
+ from durabletask.serialization import JsonDataConverter
11
12
 
12
13
  MINIMUM_INTERVAL = timedelta(seconds=1)
13
14
 
15
+ # Serializer used to (de)serialize the orchestration input to/from a JSON string
16
+ # for persistence. Matches the .NET SDK, which stores ``OrchestrationInput`` as a
17
+ # string so the Durable Task Scheduler dashboard can read the raw entity state.
18
+ _INPUT_CONVERTER = JsonDataConverter()
19
+
14
20
 
15
21
  def _validate_interval(interval: timedelta) -> timedelta:
16
22
  if interval <= timedelta(0):
@@ -25,7 +31,15 @@ def _to_iso(value: datetime | None) -> str | None:
25
31
 
26
32
 
27
33
  def _from_iso(value: str | None) -> datetime | None:
28
- return datetime.fromisoformat(value) if value else None
34
+ if not value:
35
+ return None
36
+ # .NET serializes ``DateTimeOffset`` with a numeric offset, but tolerate a
37
+ # trailing ``Z`` so states written by other producers still parse on the
38
+ # Python versions that predate ``fromisoformat`` accepting ``Z``. Only the
39
+ # trailing designator is normalized so an interior ``Z`` is left untouched.
40
+ if value.endswith("Z"):
41
+ value = value[:-1] + "+00:00"
42
+ return datetime.fromisoformat(value)
29
43
 
30
44
 
31
45
  def _interval_to_seconds(value: timedelta | None) -> float | None:
@@ -36,6 +50,119 @@ def _interval_from_seconds(value: float | None) -> timedelta | None:
36
50
  return timedelta(seconds=value) if value is not None else None
37
51
 
38
52
 
53
+ # Number of 100-nanosecond ticks per second, matching the .NET ``TimeSpan``
54
+ # resolution used when formatting the fractional component.
55
+ _TICKS_PER_SECOND = 10_000_000
56
+
57
+
58
+ def _interval_to_timespan(value: timedelta | None) -> str | None:
59
+ """Format a ``timedelta`` as a .NET ``TimeSpan`` (``[-][d.]hh:mm:ss[.fffffff]``).
60
+
61
+ The Durable Task Scheduler dashboard deserializes the schedule interval into
62
+ a .NET ``TimeSpan``, whose JSON converter only accepts this constant format.
63
+ """
64
+ if value is None:
65
+ return None
66
+ negative = value < timedelta(0)
67
+ value = abs(value)
68
+ days = value.days
69
+ hours, remainder = divmod(value.seconds, 3600)
70
+ minutes, seconds = divmod(remainder, 60)
71
+ if days:
72
+ formatted = f"{days}.{hours:02d}:{minutes:02d}:{seconds:02d}"
73
+ else:
74
+ formatted = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
75
+ if value.microseconds:
76
+ ticks = value.microseconds * 10 # microseconds -> 100-ns ticks
77
+ formatted += f".{ticks:07d}"
78
+ return f"-{formatted}" if negative else formatted
79
+
80
+
81
+ def _interval_from_timespan(value: str) -> timedelta:
82
+ """Parse a .NET ``TimeSpan`` string (``[-][d.]hh:mm:ss[.fffffff]``)."""
83
+ text = value.strip()
84
+ negative = text.startswith("-")
85
+ if negative:
86
+ text = text[1:]
87
+
88
+ fraction = 0.0
89
+ if "." in text:
90
+ head, _, tail = text.rpartition(".")
91
+ # A dot before the first ``:`` is the day separator, not a fraction.
92
+ if ":" in tail:
93
+ # e.g. ``1.02:03:04`` -- the ``.`` separates days from the clock.
94
+ days_part, _, clock = text.partition(".")
95
+ days = int(days_part)
96
+ hours, minutes, seconds = (int(p) for p in clock.split(":"))
97
+ else:
98
+ # ``head`` holds ``[d.]hh:mm:ss`` and ``tail`` the ticks fraction.
99
+ fraction = int(tail.ljust(7, "0")[:7]) / _TICKS_PER_SECOND
100
+ days, hours, minutes, seconds = _split_clock(head)
101
+ else:
102
+ days, hours, minutes, seconds = _split_clock(text)
103
+
104
+ result = timedelta(days=days, hours=hours, minutes=minutes,
105
+ seconds=seconds) + timedelta(seconds=fraction)
106
+ return -result if negative else result
107
+
108
+
109
+ def _split_clock(text: str) -> tuple[int, int, int, int]:
110
+ """Split ``[d.]hh:mm:ss`` into ``(days, hours, minutes, seconds)``."""
111
+ days = 0
112
+ if "." in text:
113
+ days_part, _, text = text.partition(".")
114
+ days = int(days_part)
115
+ hours, minutes, seconds = (int(part) for part in text.split(":"))
116
+ return days, hours, minutes, seconds
117
+
118
+
119
+ def _get(data: dict[str, Any], *keys: str, default: Any = None) -> Any:
120
+ """Return the first present key from ``data``.
121
+
122
+ Reads tolerate both the .NET-compatible PascalCase keys and the legacy
123
+ snake_case keys written by earlier Python workers.
124
+ """
125
+ for key in keys:
126
+ if key in data:
127
+ return data[key]
128
+ return default
129
+
130
+
131
+ def _parse_interval(data: dict[str, Any]) -> timedelta:
132
+ """Read the interval from either the .NET ``Interval`` or legacy field."""
133
+ timespan = _get(data, "Interval", "interval")
134
+ if isinstance(timespan, str):
135
+ return _interval_from_timespan(timespan)
136
+ seconds = _get(data, "interval_seconds")
137
+ if seconds is not None:
138
+ return timedelta(seconds=seconds)
139
+ raise KeyError("interval")
140
+
141
+
142
+ def _encode_orchestration_input(value: Any) -> str | None:
143
+ """Serialize the orchestration input to a JSON string for persistence.
144
+
145
+ The Durable Task Scheduler dashboard (and the .NET SDK) model the persisted
146
+ ``OrchestrationInput`` as a string, so the raw input object is serialized to
147
+ a JSON string here and parsed back by :func:`_decode_orchestration_input`.
148
+ """
149
+ if value is None:
150
+ return None
151
+ return _INPUT_CONVERTER.serialize(value)
152
+
153
+
154
+ def _decode_orchestration_input(value: Any) -> Any:
155
+ """Reconstruct the raw orchestration input from its persisted JSON string."""
156
+ if value is None or not isinstance(value, str):
157
+ return value
158
+ try:
159
+ return _INPUT_CONVERTER.deserialize(value)
160
+ except (ValueError, TypeError):
161
+ # Not a JSON document (an unexpected shape); return it unchanged rather
162
+ # than failing to load the schedule.
163
+ return value
164
+
165
+
39
166
  @dataclass
40
167
  class ScheduleCreationOptions:
41
168
  """Options for creating a new schedule."""
@@ -230,29 +357,39 @@ class ScheduleConfiguration:
230
357
  raise ValueError("start_at cannot be later than end_at.")
231
358
 
232
359
  def to_json(self) -> dict[str, Any]:
360
+ # Serialized with .NET-compatible property names and value shapes so the
361
+ # Durable Task Scheduler dashboard can deserialize the raw entity state:
362
+ # PascalCase keys and the interval as a .NET ``TimeSpan`` string.
233
363
  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,
364
+ "ScheduleId": self.schedule_id,
365
+ "OrchestrationName": self.orchestration_name,
366
+ "Interval": _interval_to_timespan(self.interval),
367
+ "OrchestrationInput": _encode_orchestration_input(self.orchestration_input),
368
+ "OrchestrationInstanceId": self.orchestration_instance_id,
369
+ "StartAt": _to_iso(self.start_at),
370
+ "EndAt": _to_iso(self.end_at),
371
+ "StartImmediatelyIfLate": self.start_immediately_if_late,
242
372
  }
243
373
 
244
374
  @classmethod
245
375
  def from_json(cls, data: dict[str, Any]) -> "ScheduleConfiguration":
246
376
  config = cls(
247
- data["schedule_id"],
248
- data["orchestration_name"],
249
- timedelta(seconds=data["interval_seconds"]),
377
+ _get(data, "ScheduleId", "schedule_id"),
378
+ _get(data, "OrchestrationName", "orchestration_name"),
379
+ _parse_interval(data),
250
380
  )
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))
381
+ if "OrchestrationInput" in data:
382
+ # New .NET-compatible states store the input as a JSON string.
383
+ config.orchestration_input = _decode_orchestration_input(data["OrchestrationInput"])
384
+ else:
385
+ # Legacy states stored the raw (already-parsed) input object.
386
+ config.orchestration_input = data.get("orchestration_input")
387
+ config.orchestration_instance_id = _get(
388
+ data, "OrchestrationInstanceId", "orchestration_instance_id")
389
+ config.start_at = _from_iso(_get(data, "StartAt", "start_at"))
390
+ config.end_at = _from_iso(_get(data, "EndAt", "end_at"))
391
+ config.start_immediately_if_late = bool(
392
+ _get(data, "StartImmediatelyIfLate", "start_immediately_if_late", default=False))
256
393
  return config
257
394
 
258
395
 
@@ -273,16 +410,18 @@ class ScheduleState:
273
410
 
274
411
  def to_json(self) -> dict[str, Any]:
275
412
  # ``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.
413
+ # serializer recurses into it and fires its own ``to_json`` hook. Keys
414
+ # and value shapes mirror the .NET ``ScheduleState`` so the Durable Task
415
+ # Scheduler dashboard can deserialize the raw entity state: PascalCase
416
+ # names, the status as its numeric ordinal, and datetimes as ISO strings.
278
417
  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,
418
+ "Status": self.status.to_dotnet_ordinal(),
419
+ "ExecutionToken": self.execution_token,
420
+ "LastRunAt": _to_iso(self.last_run_at),
421
+ "NextRunAt": _to_iso(self.next_run_at),
422
+ "ScheduleCreatedAt": _to_iso(self.schedule_created_at),
423
+ "ScheduleLastModifiedAt": _to_iso(self.schedule_last_modified_at),
424
+ "ScheduleConfiguration": self.schedule_configuration,
286
425
  }
287
426
 
288
427
  @classmethod
@@ -291,15 +430,22 @@ class ScheduleState:
291
430
  # ``from_json`` hook directly. ``ScheduleConfiguration`` is an internal
292
431
  # type, so there is no need to route it through a (possibly custom)
293
432
  # converter -- keeping this hook converter-free means it round-trips
294
- # under any code path, not only the worker's threaded converter.
433
+ # under any code path, not only the worker's threaded converter. Reads
434
+ # accept both the .NET-compatible and legacy snake_case shapes.
295
435
  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")
436
+ state.status = ScheduleStatus.from_dotnet(_get(data, "Status", "status"))
437
+ # Preserve the token generated by ``__init__`` when the field is absent;
438
+ # overwriting it with ``None`` would make every ``run_schedule`` signal
439
+ # look stale and silently stop the schedule.
440
+ token = _get(data, "ExecutionToken", "execution_token")
441
+ if token is not None:
442
+ state.execution_token = token
443
+ state.last_run_at = _from_iso(_get(data, "LastRunAt", "last_run_at"))
444
+ state.next_run_at = _from_iso(_get(data, "NextRunAt", "next_run_at"))
445
+ state.schedule_created_at = _from_iso(_get(data, "ScheduleCreatedAt", "schedule_created_at"))
446
+ state.schedule_last_modified_at = _from_iso(
447
+ _get(data, "ScheduleLastModifiedAt", "schedule_last_modified_at"))
448
+ config_data = _get(data, "ScheduleConfiguration", "schedule_configuration")
303
449
  state.schedule_configuration = (
304
450
  ScheduleConfiguration.from_json(config_data) if config_data is not None else None)
305
451
  return state
@@ -0,0 +1,66 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ from enum import Enum
5
+
6
+
7
+ class ScheduleStatus(str, Enum):
8
+ """Represents the current status of a schedule."""
9
+
10
+ UNINITIALIZED = "Uninitialized"
11
+ """Schedule has not been created."""
12
+
13
+ ACTIVE = "Active"
14
+ """Schedule is active and running."""
15
+
16
+ PAUSED = "Paused"
17
+ """Schedule is paused."""
18
+
19
+ def to_dotnet_ordinal(self) -> int:
20
+ """Return the numeric value used by the .NET ``ScheduleStatus`` enum.
21
+
22
+ The Durable Task Scheduler dashboard reads the persisted entity state
23
+ with ``System.Text.Json`` (Web defaults, no string-enum converter), so
24
+ the status must be serialized as the enum's ordinal rather than its
25
+ name. The ordinals match the .NET SDK order (``Uninitialized`` = 0,
26
+ ``Active`` = 1, ``Paused`` = 2).
27
+ """
28
+ return _STATUS_TO_ORDINAL[self]
29
+
30
+ @classmethod
31
+ def from_dotnet(cls, value: "int | str | None") -> "ScheduleStatus":
32
+ """Reconstruct a status from a persisted value.
33
+
34
+ Accepts the numeric ordinal written by the .NET-compatible serializer
35
+ as well as the legacy string name (e.g. ``"Active"``) so that states
36
+ persisted by older Python workers still round-trip.
37
+ """
38
+ if isinstance(value, bool):
39
+ # ``bool`` is a subclass of ``int``; reject it explicitly so a
40
+ # stray boolean cannot be misread as an ordinal.
41
+ return cls.UNINITIALIZED
42
+ if isinstance(value, int):
43
+ return _ORDINAL_TO_STATUS.get(value, cls.UNINITIALIZED)
44
+ if isinstance(value, str):
45
+ text = value.strip()
46
+ if text.isdigit():
47
+ return _ORDINAL_TO_STATUS.get(int(text), cls.UNINITIALIZED)
48
+ for member in cls:
49
+ if member.value.lower() == text.lower():
50
+ return member
51
+ # The .NET Scheduler client names the zero value "Unknown"; treat
52
+ # it as the equivalent uninitialized state.
53
+ if text.lower() == "unknown":
54
+ return cls.UNINITIALIZED
55
+ return cls.UNINITIALIZED
56
+
57
+
58
+ _STATUS_TO_ORDINAL: dict["ScheduleStatus", int] = {
59
+ ScheduleStatus.UNINITIALIZED: 0,
60
+ ScheduleStatus.ACTIVE: 1,
61
+ ScheduleStatus.PAUSED: 2,
62
+ }
63
+
64
+ _ORDINAL_TO_STATUS: dict[int, "ScheduleStatus"] = {
65
+ ordinal: status for status, ordinal in _STATUS_TO_ORDINAL.items()
66
+ }
@@ -38,6 +38,22 @@ class OrchestrationContext(ABC):
38
38
  """
39
39
  pass
40
40
 
41
+ @property
42
+ @abstractmethod
43
+ def parent_instance_id(self) -> str | None:
44
+ """Get the ID of the parent orchestration instance.
45
+
46
+ For a sub-orchestration, this is the instance ID of the orchestration
47
+ that scheduled it. For a top-level orchestration, this is ``None``.
48
+
49
+ Returns
50
+ -------
51
+ str | None
52
+ The parent orchestration instance ID, or ``None`` if this
53
+ orchestration was not scheduled by a parent orchestration.
54
+ """
55
+ pass
56
+
41
57
  @property
42
58
  @abstractmethod
43
59
  def version(self) -> str | None:
@@ -1483,6 +1483,7 @@ class _RuntimeOrchestrationContext(task.OrchestrationContext):
1483
1483
  self._registry = registry
1484
1484
  self._entity_context = OrchestrationEntityContext(instance_id)
1485
1485
  self._version: str | None = None
1486
+ self._parent_instance_id: str | None = None
1486
1487
  self._completion_status: pb.OrchestrationStatus | None = None
1487
1488
  self._received_events: dict[str, list[str | None]] = {}
1488
1489
  self._pending_events: dict[str, list[task.CancellableTask[Any]]] = {}
@@ -1638,6 +1639,10 @@ class _RuntimeOrchestrationContext(task.OrchestrationContext):
1638
1639
  def version(self) -> str | None:
1639
1640
  return self._version
1640
1641
 
1642
+ @property
1643
+ def parent_instance_id(self) -> str | None:
1644
+ return self._parent_instance_id
1645
+
1641
1646
  @property
1642
1647
  def current_utc_datetime(self) -> datetime:
1643
1648
  return self._current_utc_datetime
@@ -2222,6 +2227,16 @@ class _OrchestrationExecutor:
2222
2227
  if event.executionStarted.version:
2223
2228
  ctx._version = event.executionStarted.version.value # pyright: ignore[reportPrivateUsage]
2224
2229
 
2230
+ # Store the parent orchestration instance ID (set for
2231
+ # sub-orchestrations; absent for top-level orchestrations)
2232
+ if (
2233
+ event.executionStarted.HasField("parentInstance")
2234
+ and event.executionStarted.parentInstance.HasField("orchestrationInstance")
2235
+ ):
2236
+ ctx._parent_instance_id = ( # pyright: ignore[reportPrivateUsage]
2237
+ event.executionStarted.parentInstance.orchestrationInstance.instanceId
2238
+ )
2239
+
2225
2240
  # Store the parent trace context for propagation to child tasks
2226
2241
  if event.executionStarted.HasField("parentTraceContext"):
2227
2242
  ctx._parent_trace_context = event.executionStarted.parentTraceContext # pyright: ignore[reportPrivateUsage]
@@ -2573,10 +2588,10 @@ class _OrchestrationExecutor:
2573
2588
  raise TypeError("Unexpected sub-orchestration task type")
2574
2589
  elif event.HasField("eventRaised"):
2575
2590
  if event.eventRaised.name in ctx._entity_task_id_map: # pyright: ignore[reportPrivateUsage]
2576
- entity_id, operation, task_id = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None, None)) # pyright: ignore[reportPrivateUsage]
2577
- self._handle_entity_event_raised(ctx, event, entity_id, task_id, False)
2591
+ entity_id, operation, task_id = ctx._entity_task_id_map.pop(event.eventRaised.name, (None, None, None)) # pyright: ignore[reportPrivateUsage]
2592
+ self._handle_entity_event_raised(ctx, event, entity_id, task_id, False, operation)
2578
2593
  elif event.eventRaised.name in ctx._entity_lock_task_id_map: # pyright: ignore[reportPrivateUsage]
2579
- entity_id, task_id = ctx._entity_lock_task_id_map.get(event.eventRaised.name, (None, None)) # pyright: ignore[reportPrivateUsage]
2594
+ entity_id, task_id = ctx._entity_lock_task_id_map.pop(event.eventRaised.name, (None, None)) # pyright: ignore[reportPrivateUsage]
2580
2595
  self._handle_entity_event_raised(ctx, event, entity_id, task_id, True)
2581
2596
  else:
2582
2597
  # event names are case-insensitive
@@ -2803,7 +2818,8 @@ class _OrchestrationExecutor:
2803
2818
  event: pb.HistoryEvent,
2804
2819
  entity_id: EntityInstanceId | None,
2805
2820
  task_id: int | None,
2806
- is_lock_event: bool):
2821
+ is_lock_event: bool,
2822
+ operation: str | None = None):
2807
2823
  # This eventRaised represents the result of an entity operation after being translated to the old
2808
2824
  # entity protocol by the Durable WebJobs extension
2809
2825
  if entity_id is None:
@@ -2813,16 +2829,38 @@ class _OrchestrationExecutor:
2813
2829
  entity_task = ctx._pending_tasks.pop(task_id, None) # pyright: ignore[reportPrivateUsage]
2814
2830
  if not entity_task:
2815
2831
  raise RuntimeError(f"Could not retrieve entity task for entity-related eventRaised with ID '{event.eventId}'")
2816
- result = None
2832
+ response: dict[str, Any] | None = None
2817
2833
  if not ph.is_empty(event.eventRaised.input):
2818
- # TODO: Investigate why the event result is wrapped in a dict with "result" key
2819
- # The expected type applies to the unwrapped result value, not the
2820
- # transport wrapper. Unwrap first, then coerce the already-parsed
2821
- # inner value to the expected type via the converter (no redundant
2822
- # re-serialization round-trip).
2823
- unwrapped = self._data_converter.deserialize(event.eventRaised.input.value)["result"]
2824
- result = self._data_converter.coerce(
2825
- unwrapped,
2834
+ response = self._data_converter.deserialize(event.eventRaised.input.value)
2835
+
2836
+ # For entity operation calls (lock acquisitions never fail this way), the legacy WebJobs
2837
+ # "old protocol" ResponseMessage signals a failed operation via the presence of an
2838
+ # "exceptionType" marker (or a structured "failureDetails" object). The human-readable
2839
+ # message lives in the *serialized* "result" field -- "exceptionType" is only the exception
2840
+ # type name, not the message -- so deserialize "result" to recover it, matching
2841
+ # azure-functions-durable-python / -js. Propagate as a task failure so an awaiting
2842
+ # call_entity raises, like the current entity protocol and the .NET SDK.
2843
+ if not is_lock_event and isinstance(response, dict) and ph.is_entity_error_response(response):
2844
+ raw_result = response.get("result")
2845
+ error_content = (
2846
+ self._data_converter.deserialize(raw_result) if isinstance(raw_result, str) else raw_result
2847
+ )
2848
+ failure_details = ph.entity_response_failure_details(response, error_content)
2849
+ failure = EntityOperationFailedException(entity_id, operation or "", failure_details)
2850
+ ctx._entity_context.recover_lock_after_call(entity_id) # pyright: ignore[reportPrivateUsage]
2851
+ entity_task.fail(str(failure), failure)
2852
+ ctx.resume()
2853
+ return
2854
+
2855
+ result = None
2856
+ if response is not None:
2857
+ # The legacy protocol wraps the result as {"result": <serialized>},
2858
+ # where the value is a serialized JSON string (like the new protocol's
2859
+ # entityOperationCompleted.output). Deserialize it -- not coerce -- so
2860
+ # the value is fully parsed and the expected type applied; coercing
2861
+ # would skip JSON parsing and leave it double-encoded (e.g. '"done"').
2862
+ result = self._data_converter.deserialize(
2863
+ response["result"],
2826
2864
  entity_task._expected_type, # pyright: ignore[reportPrivateUsage]
2827
2865
  )
2828
2866
  if is_lock_event:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask
3
- Version: 1.7.0
3
+ Version: 1.7.1
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
9
9
 
10
10
  [project]
11
11
  name = "durabletask"
12
- version = "1.7.0"
12
+ version = "1.7.1"
13
13
  description = "A Durable Task Client SDK for Python"
14
14
  keywords = [
15
15
  "durable",
@@ -1,17 +0,0 @@
1
- # Copyright (c) Microsoft Corporation.
2
- # Licensed under the MIT License.
3
-
4
- from enum import Enum
5
-
6
-
7
- class ScheduleStatus(str, Enum):
8
- """Represents the current status of a schedule."""
9
-
10
- UNINITIALIZED = "Uninitialized"
11
- """Schedule has not been created."""
12
-
13
- ACTIVE = "Active"
14
- """Schedule is active and running."""
15
-
16
- PAUSED = "Paused"
17
- """Schedule is paused."""
File without changes
File without changes
File without changes