durabletask 1.4.0.dev30__tar.gz → 1.4.0.dev32__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 (47) hide show
  1. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/PKG-INFO +1 -1
  2. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/__init__.py +3 -0
  3. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/client.py +127 -19
  4. durabletask-1.4.0.dev32/durabletask/grpc_options.py +102 -0
  5. durabletask-1.4.0.dev32/durabletask/history.py +535 -0
  6. durabletask-1.4.0.dev32/durabletask/internal/history_helpers.py +68 -0
  7. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/shared.py +44 -10
  8. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/testing/in_memory_backend.py +71 -2
  9. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/worker.py +23 -7
  10. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask.egg-info/PKG-INFO +1 -1
  11. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask.egg-info/SOURCES.txt +3 -0
  12. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/pyproject.toml +1 -1
  13. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/LICENSE +0 -0
  14. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/README.md +0 -0
  15. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/entities/__init__.py +0 -0
  16. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/entities/durable_entity.py +0 -0
  17. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/entities/entity_context.py +0 -0
  18. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/entities/entity_instance_id.py +0 -0
  19. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/entities/entity_lock.py +0 -0
  20. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/entities/entity_metadata.py +0 -0
  21. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/entities/entity_operation_failed_exception.py +0 -0
  22. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/extensions/__init__.py +0 -0
  23. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/extensions/azure_blob_payloads/__init__.py +0 -0
  24. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/extensions/azure_blob_payloads/blob_payload_store.py +0 -0
  25. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/extensions/azure_blob_payloads/options.py +0 -0
  26. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/client_helpers.py +0 -0
  27. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/entity_state_shim.py +0 -0
  28. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/exceptions.py +0 -0
  29. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/grpc_interceptor.py +0 -0
  30. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/helpers.py +0 -0
  31. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/json_encode_output_exception.py +0 -0
  32. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/orchestration_entity_context.py +0 -0
  33. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/orchestrator_service_pb2.py +0 -0
  34. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/orchestrator_service_pb2.pyi +0 -0
  35. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/orchestrator_service_pb2_grpc.py +0 -0
  36. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/proto_task_hub_sidecar_service_stub.py +0 -0
  37. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/internal/tracing.py +0 -0
  38. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/payload/__init__.py +0 -0
  39. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/payload/helpers.py +0 -0
  40. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/payload/store.py +0 -0
  41. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/py.typed +0 -0
  42. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/task.py +0 -0
  43. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask/testing/__init__.py +0 -0
  44. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask.egg-info/dependency_links.txt +0 -0
  45. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask.egg-info/requires.txt +0 -0
  46. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/durabletask.egg-info/top_level.txt +0 -0
  47. {durabletask-1.4.0.dev30 → durabletask-1.4.0.dev32}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: durabletask
3
- Version: 1.4.0.dev30
3
+ Version: 1.4.0.dev32
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
  """Durable Task SDK for Python"""
5
5
 
6
+ from durabletask.grpc_options import GrpcChannelOptions, GrpcRetryPolicyOptions
6
7
  from durabletask.payload.store import LargePayloadStorageOptions, PayloadStore
7
8
  from durabletask.worker import (
8
9
  ActivityWorkItemFilter,
@@ -17,6 +18,8 @@ __all__ = [
17
18
  "ActivityWorkItemFilter",
18
19
  "ConcurrencyOptions",
19
20
  "EntityWorkItemFilter",
21
+ "GrpcChannelOptions",
22
+ "GrpcRetryPolicyOptions",
20
23
  "LargePayloadStorageOptions",
21
24
  "OrchestrationWorkItemFilter",
22
25
  "PayloadStore",
@@ -6,14 +6,17 @@ import uuid
6
6
  from dataclasses import dataclass
7
7
  from datetime import datetime
8
8
  from enum import Enum
9
- from typing import Any, List, Optional, Sequence, TypeVar, Union
9
+ from typing import Any, Generic, List, Optional, Sequence, TypeVar, Union
10
10
 
11
11
  import grpc
12
12
  import grpc.aio
13
13
 
14
+ import durabletask.history as history
14
15
  from durabletask.entities import EntityInstanceId
15
16
  from durabletask.entities.entity_metadata import EntityMetadata
17
+ from durabletask.grpc_options import GrpcChannelOptions
16
18
  import durabletask.internal.helpers as helpers
19
+ import durabletask.internal.history_helpers as history_helpers
17
20
  import durabletask.internal.orchestrator_service_pb2 as pb
18
21
  import durabletask.internal.orchestrator_service_pb2_grpc as stubs
19
22
  import durabletask.internal.shared as shared
@@ -37,6 +40,7 @@ from durabletask.payload.store import PayloadStore
37
40
 
38
41
  TInput = TypeVar('TInput')
39
42
  TOutput = TypeVar('TOutput')
43
+ TItem = TypeVar('TItem')
40
44
 
41
45
 
42
46
  class OrchestrationStatus(Enum):
@@ -99,6 +103,12 @@ class PurgeInstancesResult:
99
103
  is_complete: bool
100
104
 
101
105
 
106
+ @dataclass
107
+ class Page(Generic[TItem]):
108
+ items: List[TItem]
109
+ continuation_token: Optional[str]
110
+
111
+
102
112
  @dataclass
103
113
  class CleanEntityStorageResult:
104
114
  empty_entities_removed: int
@@ -152,18 +162,22 @@ class TaskHubGrpcClient:
152
162
  metadata: Optional[list[tuple[str, str]]] = None,
153
163
  log_handler: Optional[logging.Handler] = None,
154
164
  log_formatter: Optional[logging.Formatter] = None,
165
+ channel: Optional[grpc.Channel] = None,
155
166
  secure_channel: bool = False,
156
167
  interceptors: Optional[Sequence[shared.ClientInterceptor]] = None,
168
+ channel_options: Optional[GrpcChannelOptions] = None,
157
169
  default_version: Optional[str] = None,
158
170
  payload_store: Optional[PayloadStore] = None):
159
171
 
160
- interceptors = prepare_sync_interceptors(metadata, interceptors)
161
-
162
- channel = shared.get_grpc_channel(
163
- host_address=host_address,
164
- secure_channel=secure_channel,
165
- interceptors=interceptors
166
- )
172
+ self._owns_channel = channel is None
173
+ if channel is None:
174
+ interceptors = prepare_sync_interceptors(metadata, interceptors)
175
+ channel = shared.get_grpc_channel(
176
+ host_address=host_address,
177
+ secure_channel=secure_channel,
178
+ interceptors=interceptors,
179
+ channel_options=channel_options,
180
+ )
167
181
  self._channel = channel
168
182
  self._stub = stubs.TaskHubSidecarServiceStub(channel)
169
183
  self._logger = shared.get_logger("client", log_handler, log_formatter)
@@ -171,8 +185,15 @@ class TaskHubGrpcClient:
171
185
  self._payload_store = payload_store
172
186
 
173
187
  def close(self) -> None:
174
- """Closes the underlying gRPC channel."""
175
- self._channel.close()
188
+ """Closes the underlying gRPC channel.
189
+
190
+ Only closes channels created internally. If a pre-configured channel
191
+ was passed via the ``channel`` constructor parameter, this method is
192
+ a no-op — the caller retains ownership and is responsible for closing
193
+ it.
194
+ """
195
+ if self._owns_channel:
196
+ self._channel.close()
176
197
 
177
198
  def schedule_new_orchestration(self, orchestrator: Union[task.Orchestrator[TInput, TOutput], str], *,
178
199
  input: Optional[TInput] = None,
@@ -218,6 +239,44 @@ class TaskHubGrpcClient:
218
239
  payload_helpers.deexternalize_payloads(res, self._payload_store)
219
240
  return new_orchestration_state(req.instanceId, res)
220
241
 
242
+ def get_orchestration_history(self,
243
+ instance_id: str, *,
244
+ execution_id: Optional[str] = None,
245
+ for_work_item_processing: bool = False) -> List[history.HistoryEvent]:
246
+ req = pb.StreamInstanceHistoryRequest(
247
+ instanceId=instance_id,
248
+ executionId=helpers.get_string_value(execution_id),
249
+ forWorkItemProcessing=for_work_item_processing,
250
+ )
251
+ self._logger.info(f"Retrieving history for instance '{instance_id}'.")
252
+ stream = self._stub.StreamInstanceHistory(req)
253
+ return history_helpers.collect_history_events(stream, self._payload_store)
254
+
255
+ def list_instance_ids(self,
256
+ runtime_status: Optional[List[OrchestrationStatus]] = None,
257
+ completed_time_from: Optional[datetime] = None,
258
+ completed_time_to: Optional[datetime] = None,
259
+ page_size: Optional[int] = None,
260
+ continuation_token: Optional[str] = None) -> Page[str]:
261
+ req = pb.ListInstanceIdsRequest(
262
+ runtimeStatus=[status.value for status in runtime_status] if runtime_status else [],
263
+ completedTimeFrom=helpers.new_timestamp(completed_time_from) if completed_time_from else None,
264
+ completedTimeTo=helpers.new_timestamp(completed_time_to) if completed_time_to else None,
265
+ pageSize=page_size or 0,
266
+ lastInstanceKey=helpers.get_string_value(continuation_token),
267
+ )
268
+ self._logger.info(
269
+ "Listing terminal instance IDs with filters: "
270
+ f"runtime_status={[str(status) for status in runtime_status] if runtime_status else None}, "
271
+ f"completed_time_from={completed_time_from}, "
272
+ f"completed_time_to={completed_time_to}, "
273
+ f"page_size={page_size}, "
274
+ f"continuation_token={continuation_token}"
275
+ )
276
+ resp: pb.ListInstanceIdsResponse = self._stub.ListInstanceIds(req)
277
+ next_token = resp.lastInstanceKey.value if resp.HasField("lastInstanceKey") else None
278
+ return Page(items=list(resp.instanceIds), continuation_token=next_token)
279
+
221
280
  def get_all_orchestration_states(self,
222
281
  orchestration_query: Optional[OrchestrationQuery] = None
223
282
  ) -> List[OrchestrationState]:
@@ -433,18 +492,22 @@ class AsyncTaskHubGrpcClient:
433
492
  metadata: Optional[list[tuple[str, str]]] = None,
434
493
  log_handler: Optional[logging.Handler] = None,
435
494
  log_formatter: Optional[logging.Formatter] = None,
495
+ channel: Optional[grpc.aio.Channel] = None,
436
496
  secure_channel: bool = False,
437
497
  interceptors: Optional[Sequence[shared.AsyncClientInterceptor]] = None,
498
+ channel_options: Optional[GrpcChannelOptions] = None,
438
499
  default_version: Optional[str] = None,
439
500
  payload_store: Optional[PayloadStore] = None):
440
501
 
441
- interceptors = prepare_async_interceptors(metadata, interceptors)
442
-
443
- channel = shared.get_async_grpc_channel(
444
- host_address=host_address,
445
- secure_channel=secure_channel,
446
- interceptors=interceptors
447
- )
502
+ self._owns_channel = channel is None
503
+ if channel is None:
504
+ interceptors = prepare_async_interceptors(metadata, interceptors)
505
+ channel = shared.get_async_grpc_channel(
506
+ host_address=host_address,
507
+ secure_channel=secure_channel,
508
+ interceptors=interceptors,
509
+ channel_options=channel_options,
510
+ )
448
511
  self._channel = channel
449
512
  self._stub = stubs.TaskHubSidecarServiceStub(channel)
450
513
  self._logger = shared.get_logger("async_client", log_handler, log_formatter)
@@ -452,8 +515,15 @@ class AsyncTaskHubGrpcClient:
452
515
  self._payload_store = payload_store
453
516
 
454
517
  async def close(self) -> None:
455
- """Closes the underlying gRPC channel."""
456
- await self._channel.close()
518
+ """Closes the underlying gRPC channel.
519
+
520
+ Only closes channels created internally. If a pre-configured channel
521
+ was passed via the ``channel`` constructor parameter, this method is
522
+ a no-op — the caller retains ownership and is responsible for closing
523
+ it.
524
+ """
525
+ if self._owns_channel:
526
+ await self._channel.close()
457
527
 
458
528
  async def __aenter__(self):
459
529
  return self
@@ -502,6 +572,44 @@ class AsyncTaskHubGrpcClient:
502
572
  await payload_helpers.deexternalize_payloads_async(res, self._payload_store)
503
573
  return new_orchestration_state(req.instanceId, res)
504
574
 
575
+ async def get_orchestration_history(self,
576
+ instance_id: str, *,
577
+ execution_id: Optional[str] = None,
578
+ for_work_item_processing: bool = False) -> List[history.HistoryEvent]:
579
+ req = pb.StreamInstanceHistoryRequest(
580
+ instanceId=instance_id,
581
+ executionId=helpers.get_string_value(execution_id),
582
+ forWorkItemProcessing=for_work_item_processing,
583
+ )
584
+ self._logger.info(f"Retrieving history for instance '{instance_id}'.")
585
+ stream = self._stub.StreamInstanceHistory(req)
586
+ return await history_helpers.collect_history_events_async(stream, self._payload_store)
587
+
588
+ async def list_instance_ids(self,
589
+ runtime_status: Optional[List[OrchestrationStatus]] = None,
590
+ completed_time_from: Optional[datetime] = None,
591
+ completed_time_to: Optional[datetime] = None,
592
+ page_size: Optional[int] = None,
593
+ continuation_token: Optional[str] = None) -> Page[str]:
594
+ req = pb.ListInstanceIdsRequest(
595
+ runtimeStatus=[status.value for status in runtime_status] if runtime_status else [],
596
+ completedTimeFrom=helpers.new_timestamp(completed_time_from) if completed_time_from else None,
597
+ completedTimeTo=helpers.new_timestamp(completed_time_to) if completed_time_to else None,
598
+ pageSize=page_size or 0,
599
+ lastInstanceKey=helpers.get_string_value(continuation_token),
600
+ )
601
+ self._logger.info(
602
+ "Listing terminal instance IDs with filters: "
603
+ f"runtime_status={[str(status) for status in runtime_status] if runtime_status else None}, "
604
+ f"completed_time_from={completed_time_from}, "
605
+ f"completed_time_to={completed_time_to}, "
606
+ f"page_size={page_size}, "
607
+ f"continuation_token={continuation_token}"
608
+ )
609
+ resp: pb.ListInstanceIdsResponse = await self._stub.ListInstanceIds(req)
610
+ next_token = resp.lastInstanceKey.value if resp.HasField("lastInstanceKey") else None
611
+ return Page(items=list(resp.instanceIds), continuation_token=next_token)
612
+
505
613
  async def get_all_orchestration_states(self,
506
614
  orchestration_query: Optional[OrchestrationQuery] = None
507
615
  ) -> List[OrchestrationState]:
@@ -0,0 +1,102 @@
1
+ # Copyright (c) Microsoft Corporation.
2
+ # Licensed under the MIT License.
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass, field
7
+ import json
8
+ from typing import Any, Optional
9
+
10
+
11
+ @dataclass
12
+ class GrpcRetryPolicyOptions:
13
+ """Configuration for transport-level gRPC retries."""
14
+
15
+ max_attempts: int = 4
16
+ initial_backoff_seconds: float = 0.05
17
+ max_backoff_seconds: float = 0.25
18
+ backoff_multiplier: float = 2.0
19
+ retryable_status_codes: list[str] = field(default_factory=lambda: ["UNAVAILABLE"])
20
+
21
+ def __post_init__(self) -> None:
22
+ if self.max_attempts < 2:
23
+ raise ValueError("max_attempts must be >= 2")
24
+ if self.initial_backoff_seconds <= 0:
25
+ raise ValueError("initial_backoff_seconds must be > 0")
26
+ if self.max_backoff_seconds <= 0:
27
+ raise ValueError("max_backoff_seconds must be > 0")
28
+ if self.backoff_multiplier <= 0:
29
+ raise ValueError("backoff_multiplier must be > 0")
30
+ if self.max_backoff_seconds < self.initial_backoff_seconds:
31
+ raise ValueError("max_backoff_seconds must be >= initial_backoff_seconds")
32
+ if len(self.retryable_status_codes) == 0:
33
+ raise ValueError("retryable_status_codes cannot be empty")
34
+ # Validate that backoff values are representable as non-zero gRPC duration strings.
35
+ self._format_duration(self.initial_backoff_seconds)
36
+ self._format_duration(self.max_backoff_seconds)
37
+
38
+ @staticmethod
39
+ def _format_duration(seconds: float) -> str:
40
+ formatted = f"{seconds:.9f}".rstrip('0')
41
+ if formatted.endswith('.'):
42
+ formatted += '0'
43
+ if float(formatted) == 0:
44
+ raise ValueError(
45
+ f"Duration {seconds!r} rounds to zero; use a value large enough to "
46
+ "produce a non-zero gRPC duration string."
47
+ )
48
+ return f"{formatted}s"
49
+
50
+ def to_service_config(self) -> dict[str, Any]:
51
+ return {
52
+ "methodConfig": [
53
+ {
54
+ "name": [{}],
55
+ "retryPolicy": {
56
+ "maxAttempts": self.max_attempts,
57
+ "initialBackoff": self._format_duration(self.initial_backoff_seconds),
58
+ "maxBackoff": self._format_duration(self.max_backoff_seconds),
59
+ "backoffMultiplier": self.backoff_multiplier,
60
+ "retryableStatusCodes": self.retryable_status_codes,
61
+ },
62
+ }
63
+ ]
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class GrpcChannelOptions:
69
+ """Configuration for transport-level gRPC channel behavior."""
70
+
71
+ max_receive_message_length: Optional[int] = None
72
+ max_send_message_length: Optional[int] = None
73
+ keepalive_time_ms: Optional[int] = None
74
+ keepalive_timeout_ms: Optional[int] = None
75
+ keepalive_permit_without_calls: Optional[bool] = None
76
+ retry_policy: Optional[GrpcRetryPolicyOptions] = None
77
+ raw_options: list[tuple[str, Any]] = field(default_factory=list)
78
+
79
+ def to_grpc_options(self) -> list[tuple[str, Any]]:
80
+ options = list(self.raw_options)
81
+
82
+ if self.max_receive_message_length is not None:
83
+ options.append(("grpc.max_receive_message_length", self.max_receive_message_length))
84
+ if self.max_send_message_length is not None:
85
+ options.append(("grpc.max_send_message_length", self.max_send_message_length))
86
+ if self.keepalive_time_ms is not None:
87
+ options.append(("grpc.keepalive_time_ms", self.keepalive_time_ms))
88
+ if self.keepalive_timeout_ms is not None:
89
+ options.append(("grpc.keepalive_timeout_ms", self.keepalive_timeout_ms))
90
+ if self.keepalive_permit_without_calls is not None:
91
+ options.append(
92
+ (
93
+ "grpc.keepalive_permit_without_calls",
94
+ 1 if self.keepalive_permit_without_calls else 0,
95
+ )
96
+ )
97
+
98
+ if self.retry_policy is not None:
99
+ options.append(("grpc.enable_retries", 1))
100
+ options.append(("grpc.service_config", json.dumps(self.retry_policy.to_service_config())))
101
+
102
+ return options