durabletask 1.6.0__tar.gz → 1.7.0__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.6.0 → durabletask-1.7.0}/PKG-INFO +1 -1
  2. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/client.py +143 -30
  3. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/entities/durable_entity.py +12 -2
  4. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/entities/entity_context.py +26 -7
  5. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/entities/entity_instance_id.py +3 -0
  6. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/entities/entity_lock.py +3 -0
  7. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/entities/entity_metadata.py +50 -7
  8. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/entities/entity_operation_failed_exception.py +3 -0
  9. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/client.py +7 -19
  10. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/client_helpers.py +24 -9
  11. durabletask-1.7.0/durabletask/internal/entity_state_shim.py +128 -0
  12. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/exceptions.py +3 -0
  13. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/helpers.py +20 -3
  14. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/json_encode_output_exception.py +3 -0
  15. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/orchestration_entity_context.py +3 -0
  16. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +3 -0
  17. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/shared.py +45 -55
  18. durabletask-1.7.0/durabletask/internal/type_discovery.py +150 -0
  19. durabletask-1.7.0/durabletask/scheduled/__init__.py +36 -0
  20. durabletask-1.7.0/durabletask/scheduled/client.py +154 -0
  21. durabletask-1.7.0/durabletask/scheduled/exceptions.py +36 -0
  22. durabletask-1.7.0/durabletask/scheduled/models.py +326 -0
  23. durabletask-1.7.0/durabletask/scheduled/orchestrator.py +37 -0
  24. durabletask-1.7.0/durabletask/scheduled/schedule_entity.py +236 -0
  25. durabletask-1.7.0/durabletask/scheduled/schedule_status.py +17 -0
  26. durabletask-1.7.0/durabletask/scheduled/transitions.py +47 -0
  27. durabletask-1.7.0/durabletask/serialization.py +567 -0
  28. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/task.py +110 -11
  29. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/testing/in_memory_backend.py +68 -8
  30. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/worker.py +234 -55
  31. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask.egg-info/PKG-INFO +1 -1
  32. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask.egg-info/SOURCES.txt +10 -0
  33. {durabletask-1.6.0 → durabletask-1.7.0}/pyproject.toml +1 -1
  34. durabletask-1.6.0/durabletask/internal/entity_state_shim.py +0 -65
  35. {durabletask-1.6.0 → durabletask-1.7.0}/LICENSE +0 -0
  36. {durabletask-1.6.0 → durabletask-1.7.0}/README.md +0 -0
  37. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/__init__.py +0 -0
  38. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/entities/__init__.py +0 -0
  39. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/__init__.py +0 -0
  40. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/azure_blob_payloads/__init__.py +0 -0
  41. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +0 -0
  42. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/azure_blob_payloads/options.py +0 -0
  43. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/__init__.py +0 -0
  44. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/_constants.py +0 -0
  45. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/_internal.py +0 -0
  46. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/_logging.py +0 -0
  47. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/activities.py +0 -0
  48. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/azure_blob.py +0 -0
  49. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/entity.py +0 -0
  50. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/exceptions.py +0 -0
  51. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/models.py +0 -0
  52. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/orchestrator.py +0 -0
  53. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/serialization.py +0 -0
  54. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/transitions.py +0 -0
  55. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/extensions/history_export/writer.py +0 -0
  56. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/grpc_options.py +0 -0
  57. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/history.py +0 -0
  58. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/grpc_interceptor.py +0 -0
  59. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/grpc_resiliency.py +0 -0
  60. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/history_helpers.py +0 -0
  61. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/orchestrator_service_pb2.py +0 -0
  62. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/orchestrator_service_pb2.pyi +0 -0
  63. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
  64. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/internal/tracing.py +0 -0
  65. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/payload/__init__.py +0 -0
  66. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/payload/helpers.py +0 -0
  67. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/payload/store.py +0 -0
  68. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/py.typed +0 -0
  69. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask/testing/__init__.py +0 -0
  70. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask.egg-info/dependency_links.txt +0 -0
  71. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask.egg-info/requires.txt +0 -0
  72. {durabletask-1.6.0 → durabletask-1.7.0}/durabletask.egg-info/top_level.txt +0 -0
  73. {durabletask-1.6.0 → durabletask-1.7.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: A Durable Task Client SDK for Python
5
5
  License: MIT License
6
6
 
@@ -7,10 +7,10 @@ import threading
7
7
  import time
8
8
  import uuid
9
9
  from collections.abc import AsyncIterable, Iterable, Sequence
10
- from dataclasses import dataclass
10
+ from dataclasses import dataclass, field
11
11
  from datetime import datetime
12
12
  from enum import Enum
13
- from typing import Any, Generic, Protocol, TypeVar, cast
13
+ from typing import Any, Generic, Protocol, TypeVar, cast, overload
14
14
 
15
15
  import grpc
16
16
  import grpc.aio
@@ -50,10 +50,12 @@ from durabletask.internal.client_helpers import (
50
50
  )
51
51
  from durabletask.payload import helpers as payload_helpers
52
52
  from durabletask.payload.store import PayloadStore
53
+ from durabletask.serialization import DEFAULT_DATA_CONVERTER, DataConverter, JsonDataConverter
53
54
 
54
55
  TInput = TypeVar('TInput')
55
56
  TOutput = TypeVar('TOutput')
56
57
  TItem = TypeVar('TItem')
58
+ T = TypeVar('T')
57
59
 
58
60
 
59
61
  class OrchestrationStatus(Enum):
@@ -81,6 +83,102 @@ class OrchestrationState:
81
83
  serialized_output: str | None
82
84
  serialized_custom_status: str | None
83
85
  failure_details: task.FailureDetails | None
86
+ # Converter used by the typed accessors below. Defaults to the SDK's JSON
87
+ # converter; the client populates it with its own converter so custom
88
+ # serialization applies on the read side too. Excluded from equality/repr so
89
+ # two states with equal payloads remain equal regardless of converter.
90
+ _data_converter: DataConverter = field(
91
+ default=DEFAULT_DATA_CONVERTER, compare=False, repr=False)
92
+
93
+ @overload
94
+ def get_input(self, expected_type: type[T]) -> T | None:
95
+ ...
96
+
97
+ @overload
98
+ def get_input(self, expected_type: None = ...) -> Any:
99
+ ...
100
+
101
+ def get_input(self, expected_type: type | None = None) -> Any:
102
+ """Deserialize the orchestration's input.
103
+
104
+ Parameters
105
+ ----------
106
+ expected_type : type | None
107
+ Optional type used to reconstruct the input. When provided, the
108
+ payload is coerced to this type (dataclasses are constructed from
109
+ their dict payloads, types exposing a ``from_json()`` classmethod
110
+ are reconstructed via that hook) and the return value is typed as
111
+ ``expected_type | None``. When omitted, the raw deserialized JSON is
112
+ returned.
113
+
114
+ Returns
115
+ -------
116
+ Any
117
+ The deserialized input, or None if there is no input.
118
+ """
119
+ if self.serialized_input is None:
120
+ return None
121
+ return self._data_converter.deserialize(self.serialized_input, expected_type)
122
+
123
+ @overload
124
+ def get_output(self, expected_type: type[T]) -> T | None:
125
+ ...
126
+
127
+ @overload
128
+ def get_output(self, expected_type: None = ...) -> Any:
129
+ ...
130
+
131
+ def get_output(self, expected_type: type | None = None) -> Any:
132
+ """Deserialize the orchestration's output.
133
+
134
+ Parameters
135
+ ----------
136
+ expected_type : type | None
137
+ Optional type used to reconstruct the output. When provided, the
138
+ payload is coerced to this type (dataclasses are constructed from
139
+ their dict payloads, types exposing a ``from_json()`` classmethod
140
+ are reconstructed via that hook) and the return value is typed as
141
+ ``expected_type | None``. When omitted, the raw deserialized JSON is
142
+ returned.
143
+
144
+ Returns
145
+ -------
146
+ Any
147
+ The deserialized output, or None if there is no output.
148
+ """
149
+ if self.serialized_output is None:
150
+ return None
151
+ return self._data_converter.deserialize(self.serialized_output, expected_type)
152
+
153
+ @overload
154
+ def get_custom_status(self, expected_type: type[T]) -> T | None:
155
+ ...
156
+
157
+ @overload
158
+ def get_custom_status(self, expected_type: None = ...) -> Any:
159
+ ...
160
+
161
+ def get_custom_status(self, expected_type: type | None = None) -> Any:
162
+ """Deserialize the orchestration's custom status.
163
+
164
+ Parameters
165
+ ----------
166
+ expected_type : type | None
167
+ Optional type used to reconstruct the custom status. When provided,
168
+ the payload is coerced to this type (dataclasses are constructed
169
+ from their dict payloads, types exposing a ``from_json()``
170
+ classmethod are reconstructed via that hook) and the return value is
171
+ typed as ``expected_type | None``. When omitted, the raw
172
+ deserialized JSON is returned.
173
+
174
+ Returns
175
+ -------
176
+ Any
177
+ The deserialized custom status, or None if there is no custom status.
178
+ """
179
+ if self.serialized_custom_status is None:
180
+ return None
181
+ return self._data_converter.deserialize(self.serialized_custom_status, expected_type)
84
182
 
85
183
  def raise_if_failed(self):
86
184
  if self.failure_details is not None:
@@ -138,18 +236,22 @@ class OrchestrationFailedError(Exception):
138
236
  return self._failure_details
139
237
 
140
238
 
141
- def new_orchestration_state(instance_id: str, res: pb.GetInstanceResponse) -> OrchestrationState | None:
239
+ def new_orchestration_state(
240
+ instance_id: str, res: pb.GetInstanceResponse,
241
+ data_converter: DataConverter | None = None) -> OrchestrationState | None:
142
242
  if not res.exists:
143
243
  return None
144
244
 
145
245
  state = res.orchestrationState
146
246
 
147
- new_state = parse_orchestration_state(state)
247
+ new_state = parse_orchestration_state(state, data_converter)
148
248
  new_state.instance_id = instance_id # Override instance_id with the one from the request, to match old behavior
149
249
  return new_state
150
250
 
151
251
 
152
- def parse_orchestration_state(state: pb.OrchestrationState) -> OrchestrationState:
252
+ def parse_orchestration_state(
253
+ state: pb.OrchestrationState,
254
+ data_converter: DataConverter | None = None) -> OrchestrationState:
153
255
  failure_details = None
154
256
  if state.failureDetails.errorMessage != '' or state.failureDetails.errorType != '':
155
257
  failure_details = task.FailureDetails(
@@ -166,7 +268,8 @@ def parse_orchestration_state(state: pb.OrchestrationState) -> OrchestrationStat
166
268
  state.input.value if not helpers.is_empty(state.input) else None,
167
269
  state.output.value if not helpers.is_empty(state.output) else None,
168
270
  state.customStatus.value if not helpers.is_empty(state.customStatus) else None,
169
- failure_details)
271
+ failure_details,
272
+ data_converter if data_converter is not None else DEFAULT_DATA_CONVERTER)
170
273
 
171
274
 
172
275
  # Grace period before a retired SDK-owned channel is force-closed. Long enough
@@ -309,9 +412,11 @@ class TaskHubGrpcClient:
309
412
  channel_options: GrpcChannelOptions | None = None,
310
413
  resiliency_options: GrpcClientResiliencyOptions | None = None,
311
414
  default_version: str | None = None,
312
- payload_store: PayloadStore | None = None):
415
+ payload_store: PayloadStore | None = None,
416
+ data_converter: DataConverter | None = None):
313
417
 
314
418
  self._owns_channel = channel is None
419
+ self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
315
420
  self._host_address = (
316
421
  host_address if host_address else shared.get_default_host_address()
317
422
  )
@@ -514,7 +619,8 @@ class TaskHubGrpcClient:
514
619
  req = build_schedule_new_orchestration_req(
515
620
  orchestrator, input=input, instance_id=instance_id, start_at=start_at,
516
621
  reuse_id_policy=reuse_id_policy, tags=tags,
517
- version=version if version else self.default_version)
622
+ version=version if version else self.default_version,
623
+ data_converter=self._data_converter)
518
624
 
519
625
  # Inject the active PRODUCER span context into the request so the sidecar
520
626
  # stores it in the executionStarted event and the worker can parent all
@@ -538,7 +644,7 @@ class TaskHubGrpcClient:
538
644
  # De-externalize any large-payload tokens in the response
539
645
  if self._payload_store is not None and res.exists:
540
646
  payload_helpers.deexternalize_payloads(res, self._payload_store)
541
- return new_orchestration_state(req.instanceId, res)
647
+ return new_orchestration_state(req.instanceId, res, self._data_converter)
542
648
 
543
649
  def get_orchestration_history(self,
544
650
  instance_id: str, *,
@@ -594,7 +700,7 @@ class TaskHubGrpcClient:
594
700
  resp: pb.QueryInstancesResponse = self._stub.QueryInstances(req)
595
701
  if self._payload_store is not None:
596
702
  payload_helpers.deexternalize_payloads(resp, self._payload_store)
597
- states += [parse_orchestration_state(res) for res in resp.orchestrationState]
703
+ states += [parse_orchestration_state(res, self._data_converter) for res in resp.orchestrationState]
598
704
  if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
599
705
  _continuation_token = resp.continuationToken
600
706
  else:
@@ -614,7 +720,7 @@ class TaskHubGrpcClient:
614
720
  )
615
721
  if self._payload_store is not None and res.exists:
616
722
  payload_helpers.deexternalize_payloads(res, self._payload_store)
617
- return new_orchestration_state(req.instanceId, res)
723
+ return new_orchestration_state(req.instanceId, res, self._data_converter)
618
724
  except grpc.RpcError as rpc_error:
619
725
  if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED: # type: ignore
620
726
  # Replace gRPC error with the built-in TimeoutError
@@ -634,7 +740,7 @@ class TaskHubGrpcClient:
634
740
  )
635
741
  if self._payload_store is not None and res.exists:
636
742
  payload_helpers.deexternalize_payloads(res, self._payload_store)
637
- state = new_orchestration_state(req.instanceId, res)
743
+ state = new_orchestration_state(req.instanceId, res, self._data_converter)
638
744
  log_completion_state(self._logger, instance_id, state)
639
745
  return state
640
746
  except grpc.RpcError as rpc_error:
@@ -646,7 +752,7 @@ class TaskHubGrpcClient:
646
752
  def raise_orchestration_event(self, instance_id: str, event_name: str, *,
647
753
  data: Any | None = None) -> None:
648
754
  with tracing.start_raise_event_span(event_name, instance_id):
649
- req = build_raise_event_req(instance_id, event_name, data)
755
+ req = build_raise_event_req(instance_id, event_name, data, self._data_converter)
650
756
  self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
651
757
  if self._payload_store is not None:
652
758
  payload_helpers.externalize_payloads(
@@ -657,7 +763,7 @@ class TaskHubGrpcClient:
657
763
  def terminate_orchestration(self, instance_id: str, *,
658
764
  output: Any | None = None,
659
765
  recursive: bool = True) -> None:
660
- req = build_terminate_req(instance_id, output, recursive)
766
+ req = build_terminate_req(instance_id, output, recursive, self._data_converter)
661
767
 
662
768
  self._logger.info(f"Terminating instance '{instance_id}'.")
663
769
  if self._payload_store is not None:
@@ -719,8 +825,10 @@ class TaskHubGrpcClient:
719
825
  def signal_entity(self,
720
826
  entity_instance_id: EntityInstanceId,
721
827
  operation_name: str,
722
- input: Any | None = None) -> None:
723
- req = build_signal_entity_req(entity_instance_id, operation_name, input)
828
+ input: Any | None = None,
829
+ signal_time: datetime | None = None) -> None:
830
+ req = build_signal_entity_req(
831
+ entity_instance_id, operation_name, input, signal_time, self._data_converter)
724
832
  self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
725
833
  if self._payload_store is not None:
726
834
  payload_helpers.externalize_payloads(
@@ -739,7 +847,7 @@ class TaskHubGrpcClient:
739
847
  return None
740
848
  if self._payload_store is not None:
741
849
  payload_helpers.deexternalize_payloads(res, self._payload_store)
742
- return EntityMetadata.from_entity_metadata(res.entity, include_state)
850
+ return EntityMetadata.from_entity_metadata(res.entity, include_state, self._data_converter)
743
851
 
744
852
  def get_all_entities(self,
745
853
  entity_query: EntityQuery | None = None) -> list[EntityMetadata]:
@@ -756,7 +864,7 @@ class TaskHubGrpcClient:
756
864
  resp: pb.QueryEntitiesResponse = self._stub.QueryEntities(query_request)
757
865
  if self._payload_store is not None:
758
866
  payload_helpers.deexternalize_payloads(resp, self._payload_store)
759
- entities += [EntityMetadata.from_entity_metadata(entity, query_request.query.includeState) for entity in resp.entities]
867
+ entities += [EntityMetadata.from_entity_metadata(entity, query_request.query.includeState, self._data_converter) for entity in resp.entities]
760
868
  if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
761
869
  _continuation_token = resp.continuationToken
762
870
  else:
@@ -805,9 +913,11 @@ class AsyncTaskHubGrpcClient:
805
913
  channel_options: GrpcChannelOptions | None = None,
806
914
  resiliency_options: GrpcClientResiliencyOptions | None = None,
807
915
  default_version: str | None = None,
808
- payload_store: PayloadStore | None = None):
916
+ payload_store: PayloadStore | None = None,
917
+ data_converter: DataConverter | None = None):
809
918
 
810
919
  self._owns_channel = channel is None
920
+ self._data_converter = data_converter if data_converter is not None else JsonDataConverter()
811
921
  self._host_address = (
812
922
  host_address if host_address else shared.get_default_host_address()
813
923
  )
@@ -998,7 +1108,8 @@ class AsyncTaskHubGrpcClient:
998
1108
  req = build_schedule_new_orchestration_req(
999
1109
  orchestrator, input=input, instance_id=instance_id, start_at=start_at,
1000
1110
  reuse_id_policy=reuse_id_policy, tags=tags,
1001
- version=version if version else self.default_version)
1111
+ version=version if version else self.default_version,
1112
+ data_converter=self._data_converter)
1002
1113
 
1003
1114
  parent_trace_ctx = tracing.get_current_trace_context()
1004
1115
  if parent_trace_ctx is not None:
@@ -1019,7 +1130,7 @@ class AsyncTaskHubGrpcClient:
1019
1130
  res: pb.GetInstanceResponse = await self._stub.GetInstance(req)
1020
1131
  if self._payload_store is not None and res.exists:
1021
1132
  await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
1022
- return new_orchestration_state(req.instanceId, res)
1133
+ return new_orchestration_state(req.instanceId, res, self._data_converter)
1023
1134
 
1024
1135
  async def get_orchestration_history(self,
1025
1136
  instance_id: str, *,
@@ -1075,7 +1186,7 @@ class AsyncTaskHubGrpcClient:
1075
1186
  resp: pb.QueryInstancesResponse = await self._stub.QueryInstances(req)
1076
1187
  if self._payload_store is not None:
1077
1188
  await payload_helpers.deexternalize_payloads_async(resp, self._payload_store)
1078
- states += [parse_orchestration_state(res) for res in resp.orchestrationState]
1189
+ states += [parse_orchestration_state(res, self._data_converter) for res in resp.orchestrationState]
1079
1190
  if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
1080
1191
  _continuation_token = resp.continuationToken
1081
1192
  else:
@@ -1095,7 +1206,7 @@ class AsyncTaskHubGrpcClient:
1095
1206
  )
1096
1207
  if self._payload_store is not None and res.exists:
1097
1208
  await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
1098
- return new_orchestration_state(req.instanceId, res)
1209
+ return new_orchestration_state(req.instanceId, res, self._data_converter)
1099
1210
  except grpc.aio.AioRpcError as rpc_error:
1100
1211
  if rpc_error.code() == grpc.StatusCode.DEADLINE_EXCEEDED:
1101
1212
  raise TimeoutError("Timed-out waiting for the orchestration to start")
@@ -1114,7 +1225,7 @@ class AsyncTaskHubGrpcClient:
1114
1225
  )
1115
1226
  if self._payload_store is not None and res.exists:
1116
1227
  await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
1117
- state = new_orchestration_state(req.instanceId, res)
1228
+ state = new_orchestration_state(req.instanceId, res, self._data_converter)
1118
1229
  log_completion_state(self._logger, instance_id, state)
1119
1230
  return state
1120
1231
  except grpc.aio.AioRpcError as rpc_error:
@@ -1126,7 +1237,7 @@ class AsyncTaskHubGrpcClient:
1126
1237
  async def raise_orchestration_event(self, instance_id: str, event_name: str, *,
1127
1238
  data: Any | None = None) -> None:
1128
1239
  with tracing.start_raise_event_span(event_name, instance_id):
1129
- req = build_raise_event_req(instance_id, event_name, data)
1240
+ req = build_raise_event_req(instance_id, event_name, data, self._data_converter)
1130
1241
  self._logger.info(f"Raising event '{event_name}' for instance '{instance_id}'.")
1131
1242
  if self._payload_store is not None:
1132
1243
  await payload_helpers.externalize_payloads_async(
@@ -1137,7 +1248,7 @@ class AsyncTaskHubGrpcClient:
1137
1248
  async def terminate_orchestration(self, instance_id: str, *,
1138
1249
  output: Any | None = None,
1139
1250
  recursive: bool = True) -> None:
1140
- req = build_terminate_req(instance_id, output, recursive)
1251
+ req = build_terminate_req(instance_id, output, recursive, self._data_converter)
1141
1252
 
1142
1253
  self._logger.info(f"Terminating instance '{instance_id}'.")
1143
1254
  if self._payload_store is not None:
@@ -1199,8 +1310,10 @@ class AsyncTaskHubGrpcClient:
1199
1310
  async def signal_entity(self,
1200
1311
  entity_instance_id: EntityInstanceId,
1201
1312
  operation_name: str,
1202
- input: Any | None = None) -> None:
1203
- req = build_signal_entity_req(entity_instance_id, operation_name, input)
1313
+ input: Any | None = None,
1314
+ signal_time: datetime | None = None) -> None:
1315
+ req = build_signal_entity_req(
1316
+ entity_instance_id, operation_name, input, signal_time, self._data_converter)
1204
1317
  self._logger.info(f"Signaling entity '{entity_instance_id}' operation '{operation_name}'.")
1205
1318
  if self._payload_store is not None:
1206
1319
  await payload_helpers.externalize_payloads_async(
@@ -1219,7 +1332,7 @@ class AsyncTaskHubGrpcClient:
1219
1332
  return None
1220
1333
  if self._payload_store is not None:
1221
1334
  await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
1222
- return EntityMetadata.from_entity_metadata(res.entity, include_state)
1335
+ return EntityMetadata.from_entity_metadata(res.entity, include_state, self._data_converter)
1223
1336
 
1224
1337
  async def get_all_entities(self,
1225
1338
  entity_query: EntityQuery | None = None) -> list[EntityMetadata]:
@@ -1236,7 +1349,7 @@ class AsyncTaskHubGrpcClient:
1236
1349
  resp: pb.QueryEntitiesResponse = await self._stub.QueryEntities(query_request)
1237
1350
  if self._payload_store is not None:
1238
1351
  await payload_helpers.deexternalize_payloads_async(resp, self._payload_store)
1239
- entities += [EntityMetadata.from_entity_metadata(entity, query_request.query.includeState) for entity in resp.entities]
1352
+ entities += [EntityMetadata.from_entity_metadata(entity, query_request.query.includeState, self._data_converter) for entity in resp.entities]
1240
1353
  if check_continuation_token(resp.continuationToken, _continuation_token, self._logger):
1241
1354
  _continuation_token = resp.continuationToken
1242
1355
  else:
@@ -1,4 +1,8 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
1
4
  from typing import Any, TypeVar, overload
5
+ from datetime import datetime
2
6
 
3
7
  from durabletask.entities.entity_context import EntityContext
4
8
  from durabletask.entities.entity_instance_id import EntityInstanceId
@@ -49,7 +53,9 @@ class DurableEntity:
49
53
  """
50
54
  self.entity_context.set_state(state)
51
55
 
52
- def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, input: Any | None = None) -> None:
56
+ def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str,
57
+ input: Any | None = None,
58
+ signal_time: datetime | None = None) -> None:
53
59
  """Signal another entity to perform an operation.
54
60
 
55
61
  Parameters
@@ -60,8 +66,12 @@ class DurableEntity:
60
66
  The operation to perform on the entity.
61
67
  input : Any, optional
62
68
  The input to provide to the entity for the operation.
69
+ signal_time : datetime, optional
70
+ The time at which the signal should be delivered. If None, the signal is
71
+ delivered as soon as possible. Use this to schedule a future operation,
72
+ for example to have an entity wake itself up at a later time.
63
73
  """
64
- self.entity_context.signal_entity(entity_instance_id, operation, input)
74
+ self.entity_context.signal_entity(entity_instance_id, operation, input, signal_time)
65
75
 
66
76
  def schedule_new_orchestration(self, orchestration_name: str, input: Any | None = None, instance_id: str | None = None) -> str:
67
77
  """Schedule a new orchestration instance.
@@ -1,20 +1,29 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
1
3
 
2
- from typing import Any, TypeVar, overload
4
+ from datetime import datetime
5
+ from typing import TYPE_CHECKING, Any, TypeVar, overload
3
6
  import uuid
7
+ from google.protobuf import timestamp_pb2
4
8
  from durabletask.entities.entity_instance_id import EntityInstanceId
5
- from durabletask.internal import helpers, shared
9
+ from durabletask.internal import helpers
6
10
  from durabletask.internal.entity_state_shim import StateShim
7
11
  import durabletask.internal.orchestrator_service_pb2 as pb
8
12
 
13
+ if TYPE_CHECKING:
14
+ from durabletask.serialization import DataConverter
15
+
9
16
  TState = TypeVar("TState")
10
17
 
11
18
 
12
19
  class EntityContext:
13
- def __init__(self, orchestration_id: str, operation: str, state: StateShim, entity_id: EntityInstanceId):
20
+ def __init__(self, orchestration_id: str, operation: str, state: StateShim,
21
+ entity_id: EntityInstanceId, data_converter: "DataConverter"):
14
22
  self._orchestration_id = orchestration_id
15
23
  self._operation = operation
16
24
  self._state = state
17
25
  self._entity_id = entity_id
26
+ self._data_converter = data_converter
18
27
 
19
28
  @property
20
29
  def orchestration_id(self) -> str:
@@ -81,7 +90,9 @@ class EntityContext:
81
90
  """
82
91
  self._state.set_state(new_state)
83
92
 
84
- def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str, input: Any | None = None) -> None:
93
+ def signal_entity(self, entity_instance_id: EntityInstanceId, operation: str,
94
+ input: Any | None = None,
95
+ signal_time: datetime | None = None) -> None:
85
96
  """Signal another entity to perform an operation.
86
97
 
87
98
  Parameters
@@ -92,15 +103,23 @@ class EntityContext:
92
103
  The operation to perform on the entity.
93
104
  input : Any, optional
94
105
  The input to provide to the entity for the operation.
106
+ signal_time : datetime, optional
107
+ The time at which the signal should be delivered. If None, the signal is
108
+ delivered as soon as possible. Use this to schedule a future operation,
109
+ for example to have an entity wake itself up at a later time.
95
110
  """
96
- encoded_input: str | None = shared.to_json(input) if input is not None else None
111
+ encoded_input: str | None = self._data_converter.serialize(input)
112
+ scheduled_time: timestamp_pb2.Timestamp | None = None
113
+ if signal_time is not None:
114
+ scheduled_time = timestamp_pb2.Timestamp()
115
+ scheduled_time.FromDatetime(signal_time)
97
116
  self._state.add_operation_action(
98
117
  pb.OperationAction(
99
118
  sendSignal=pb.SendSignalAction(
100
119
  instanceId=str(entity_instance_id),
101
120
  name=operation,
102
121
  input=helpers.get_string_value(encoded_input),
103
- scheduledTime=None,
122
+ scheduledTime=scheduled_time,
104
123
  requestTime=None,
105
124
  parentTraceContext=None,
106
125
  )
@@ -124,7 +143,7 @@ class EntityContext:
124
143
  str
125
144
  The instance ID of the scheduled orchestration.
126
145
  """
127
- encoded_input: str | None = shared.to_json(input) if input is not None else None
146
+ encoded_input: str | None = self._data_converter.serialize(input)
128
147
  if not instance_id:
129
148
  instance_id = uuid.uuid4().hex
130
149
  self._state.add_operation_action(
@@ -1,3 +1,6 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
1
4
  class EntityInstanceId:
2
5
  def __init__(self, entity: str, key: str):
3
6
  EntityInstanceId.validate_entity_name(entity)
@@ -1,3 +1,6 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
1
4
  from __future__ import annotations
2
5
 
3
6
  from typing import TYPE_CHECKING
@@ -1,9 +1,15 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
1
4
  from datetime import datetime, timezone
2
- from typing import Any, TypeVar, overload
5
+ from typing import TYPE_CHECKING, Any, TypeVar, overload
3
6
  from durabletask.entities.entity_instance_id import EntityInstanceId
4
7
 
5
8
  import durabletask.internal.orchestrator_service_pb2 as pb
6
9
 
10
+ if TYPE_CHECKING:
11
+ from durabletask.serialization import DataConverter
12
+
7
13
  TState = TypeVar("TState")
8
14
 
9
15
 
@@ -29,7 +35,8 @@ class EntityMetadata:
29
35
  backlog_queue_size: int,
30
36
  locked_by: str,
31
37
  includes_state: bool,
32
- state: Any | None):
38
+ state: Any | None,
39
+ data_converter: "DataConverter"):
33
40
  """Initializes a new instance of the EntityMetadata class.
34
41
 
35
42
  Args:
@@ -41,13 +48,17 @@ class EntityMetadata:
41
48
  self._locked_by = locked_by
42
49
  self.includes_state = includes_state
43
50
  self._state = state
51
+ self._data_converter = data_converter
44
52
 
45
53
  @staticmethod
46
- def from_entity_response(entity_response: pb.GetEntityResponse, includes_state: bool):
47
- return EntityMetadata.from_entity_metadata(entity_response.entity, includes_state)
54
+ def from_entity_response(entity_response: pb.GetEntityResponse, includes_state: bool,
55
+ data_converter: "DataConverter"):
56
+ return EntityMetadata.from_entity_metadata(
57
+ entity_response.entity, includes_state, data_converter)
48
58
 
49
59
  @staticmethod
50
- def from_entity_metadata(entity: pb.EntityMetadata, includes_state: bool):
60
+ def from_entity_metadata(entity: pb.EntityMetadata, includes_state: bool,
61
+ data_converter: "DataConverter"):
51
62
  try:
52
63
  entity_id = EntityInstanceId.parse(entity.instanceId)
53
64
  except ValueError:
@@ -61,7 +72,8 @@ class EntityMetadata:
61
72
  backlog_queue_size=entity.backlogQueueSize,
62
73
  locked_by=entity.lockedBy.value,
63
74
  includes_state=includes_state,
64
- state=entity_state
75
+ state=entity_state,
76
+ data_converter=data_converter,
65
77
  )
66
78
 
67
79
  @overload
@@ -73,7 +85,17 @@ class EntityMetadata:
73
85
  ...
74
86
 
75
87
  def get_state(self, intended_type: type[TState] | None = None) -> TState | Any | None:
76
- """Get the current state of the entity, optionally converting it to a specified type."""
88
+ """Get the entity's raw persisted state, optionally constructor-coerced.
89
+
90
+ The state is held as the raw serialized JSON payload (a ``str``). With no
91
+ argument the raw payload is returned unchanged; passing ``intended_type``
92
+ applies the legacy constructor-based coercion (``intended_type(raw)``)
93
+ and raises ``TypeError`` if that fails.
94
+
95
+ This preserves the pre-existing contract. To deserialize the payload and
96
+ reconstruct dataclasses or ``from_json()``-capable types, use
97
+ :meth:`get_typed_state` instead.
98
+ """
77
99
  if intended_type is None or self._state is None:
78
100
  return self._state
79
101
 
@@ -87,6 +109,27 @@ class EntityMetadata:
87
109
  f"Could not convert state of type '{type(self._state).__name__}' to '{intended_type.__name__}'"
88
110
  ) from ex
89
111
 
112
+ @overload
113
+ def get_typed_state(self, intended_type: type[TState]) -> TState | None:
114
+ ...
115
+
116
+ @overload
117
+ def get_typed_state(self, intended_type: None = None) -> Any:
118
+ ...
119
+
120
+ def get_typed_state(self, intended_type: type[TState] | None = None) -> TState | Any | None:
121
+ """Deserialize the entity's persisted state, optionally reconstructing a type.
122
+
123
+ The state is stored as its raw serialized JSON payload and deserialized
124
+ here. When ``intended_type`` is provided the payload is reconstructed as
125
+ that type (dataclasses, ``from_json()``-capable types, etc.); otherwise
126
+ the plain deserialized JSON value is returned.
127
+ """
128
+ if self._state is None:
129
+ return None
130
+
131
+ return self._data_converter.deserialize(self._state, intended_type)
132
+
90
133
  def get_locked_by(self) -> EntityInstanceId | None:
91
134
  """Get the identifier of the worker that currently holds the lock on the entity.
92
135
 
@@ -1,3 +1,6 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
1
4
  from durabletask.internal.orchestrator_service_pb2 import TaskFailureDetails
2
5
  from durabletask.entities.entity_instance_id import EntityInstanceId
3
6