cadence-python-client 0.2.2__py3-none-any.whl → 0.2.3__py3-none-any.whl

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.
@@ -1,7 +1,7 @@
1
- from typing import Callable, Any, Optional, Generator, TypeVar
1
+ from typing import Callable, Any, Optional, Generator, TypeVar, cast
2
2
 
3
3
  import grpc
4
- from google.rpc.status_pb2 import Status # type: ignore
4
+ from google.rpc.status_pb2 import Status
5
5
  from grpc.aio import (
6
6
  UnaryUnaryClientInterceptor,
7
7
  ClientCallDetails,
@@ -9,7 +9,7 @@ from grpc.aio import (
9
9
  UnaryUnaryCall,
10
10
  Metadata,
11
11
  )
12
- from grpc_status.rpc_status import from_call # type: ignore
12
+ from grpc_status.rpc_status import from_call
13
13
 
14
14
  from cadence.api.v1 import error_pb2
15
15
  from cadence import error
@@ -46,22 +46,22 @@ class CadenceErrorUnaryUnaryCall(UnaryUnaryCall[RequestType, ResponseType]):
46
46
  return await self._wrapped.code()
47
47
 
48
48
  async def details(self) -> str:
49
- return await self._wrapped.details() # type: ignore
49
+ return await self._wrapped.details()
50
50
 
51
51
  async def wait_for_connection(self) -> None:
52
52
  await self._wrapped.wait_for_connection()
53
53
 
54
54
  def cancelled(self) -> bool:
55
- return self._wrapped.cancelled() # type: ignore
55
+ return self._wrapped.cancelled()
56
56
 
57
57
  def done(self) -> bool:
58
- return self._wrapped.done() # type: ignore
58
+ return self._wrapped.done()
59
59
 
60
60
  def time_remaining(self) -> Optional[float]:
61
- return self._wrapped.time_remaining() # type: ignore
61
+ return self._wrapped.time_remaining()
62
62
 
63
63
  def cancel(self) -> bool:
64
- return self._wrapped.cancel() # type: ignore
64
+ return self._wrapped.cancel()
65
65
 
66
66
  def add_done_callback(self, callback: DoneCallbackType) -> None:
67
67
  self._wrapped.add_done_callback(callback)
@@ -79,16 +79,18 @@ class CadenceErrorInterceptor(UnaryUnaryClientInterceptor):
79
79
 
80
80
 
81
81
  def map_error(e: AioRpcError) -> error.CadenceRpcError:
82
- status: Status | None = from_call(e)
82
+ # AioRpcError implements the grpc.Call interface but doesn't inherit from it in the type stubs
83
+ status: Status | None = from_call(cast(grpc.Call, e))
84
+ message = e.details() or ""
83
85
  if not status or not status.details:
84
- return error.CadenceRpcError(e.details(), e.code())
86
+ return error.CadenceRpcError(message, e.code())
85
87
 
86
88
  details = status.details[0]
87
89
  if details.Is(error_pb2.WorkflowExecutionAlreadyStartedError.DESCRIPTOR):
88
90
  already_started = error_pb2.WorkflowExecutionAlreadyStartedError()
89
91
  details.Unpack(already_started)
90
92
  return error.WorkflowExecutionAlreadyStartedError(
91
- e.details(),
93
+ message,
92
94
  e.code(),
93
95
  already_started.start_request_id,
94
96
  already_started.run_id,
@@ -97,19 +99,19 @@ def map_error(e: AioRpcError) -> error.CadenceRpcError:
97
99
  not_exists = error_pb2.EntityNotExistsError()
98
100
  details.Unpack(not_exists)
99
101
  return error.EntityNotExistsError(
100
- e.details(),
102
+ message,
101
103
  e.code(),
102
104
  not_exists.current_cluster,
103
105
  not_exists.active_cluster,
104
106
  list(not_exists.active_clusters),
105
107
  )
106
108
  elif details.Is(error_pb2.WorkflowExecutionAlreadyCompletedError.DESCRIPTOR):
107
- return error.WorkflowExecutionAlreadyCompletedError(e.details(), e.code())
109
+ return error.WorkflowExecutionAlreadyCompletedError(message, e.code())
108
110
  elif details.Is(error_pb2.DomainNotActiveError.DESCRIPTOR):
109
111
  not_active = error_pb2.DomainNotActiveError()
110
112
  details.Unpack(not_active)
111
113
  return error.DomainNotActiveError(
112
- e.details(),
114
+ message,
113
115
  e.code(),
114
116
  not_active.domain,
115
117
  not_active.current_cluster,
@@ -120,7 +122,7 @@ def map_error(e: AioRpcError) -> error.CadenceRpcError:
120
122
  not_supported = error_pb2.ClientVersionNotSupportedError()
121
123
  details.Unpack(not_supported)
122
124
  return error.ClientVersionNotSupportedError(
123
- e.details(),
125
+ message,
124
126
  e.code(),
125
127
  not_supported.feature_version,
126
128
  not_supported.client_impl,
@@ -129,20 +131,18 @@ def map_error(e: AioRpcError) -> error.CadenceRpcError:
129
131
  elif details.Is(error_pb2.FeatureNotEnabledError.DESCRIPTOR):
130
132
  not_enabled = error_pb2.FeatureNotEnabledError()
131
133
  details.Unpack(not_enabled)
132
- return error.FeatureNotEnabledError(
133
- e.details(), e.code(), not_enabled.feature_flag
134
- )
134
+ return error.FeatureNotEnabledError(message, e.code(), not_enabled.feature_flag)
135
135
  elif details.Is(error_pb2.CancellationAlreadyRequestedError.DESCRIPTOR):
136
- return error.CancellationAlreadyRequestedError(e.details(), e.code())
136
+ return error.CancellationAlreadyRequestedError(message, e.code())
137
137
  elif details.Is(error_pb2.DomainAlreadyExistsError.DESCRIPTOR):
138
- return error.DomainAlreadyExistsError(e.details(), e.code())
138
+ return error.DomainAlreadyExistsError(message, e.code())
139
139
  elif details.Is(error_pb2.LimitExceededError.DESCRIPTOR):
140
- return error.LimitExceededError(e.details(), e.code())
140
+ return error.LimitExceededError(message, e.code())
141
141
  elif details.Is(error_pb2.QueryFailedError.DESCRIPTOR):
142
- return error.QueryFailedError(e.details(), e.code())
142
+ return error.QueryFailedError(message, e.code())
143
143
  elif details.Is(error_pb2.ServiceBusyError.DESCRIPTOR):
144
144
  service_busy = error_pb2.ServiceBusyError()
145
145
  details.Unpack(service_busy)
146
- return error.ServiceBusyError(e.details(), e.code(), service_busy.reason)
146
+ return error.ServiceBusyError(message, e.code(), service_busy.reason)
147
147
  else:
148
- return error.CadenceRpcError(e.details(), e.code())
148
+ return error.CadenceRpcError(message, e.code())
@@ -1,6 +1,6 @@
1
1
  import asyncio
2
2
  from dataclasses import dataclass
3
- from typing import Callable, Any
3
+ from typing import Callable, Any, cast
4
4
 
5
5
  from grpc import StatusCode
6
6
  from grpc.aio import UnaryUnaryClientInterceptor, ClientCallDetails
@@ -59,19 +59,23 @@ class RetryInterceptor(UnaryUnaryClientInterceptor):
59
59
  request: Any,
60
60
  ) -> Any:
61
61
  loop = asyncio.get_running_loop()
62
- expiration_interval = client_call_details.timeout
63
- if expiration_interval is None:
64
- expiration_interval = float("inf")
62
+ expiration_interval = (
63
+ client_call_details.timeout
64
+ if client_call_details.timeout is not None
65
+ else float("inf")
66
+ )
65
67
  start_time = loop.time()
66
68
  deadline = start_time + expiration_interval
67
69
 
68
70
  attempts = 0
69
71
  while True:
70
72
  remaining = deadline - loop.time()
71
- # Namedtuple methods start with an underscore to avoid conflicts and aren't actually private
72
- # noinspection PyProtectedMember
73
- call_details = client_call_details._replace( # type: ignore[attr-defined]
74
- timeout=remaining
73
+ call_details = ClientCallDetails(
74
+ method=client_call_details.method,
75
+ timeout=remaining,
76
+ metadata=client_call_details.metadata,
77
+ credentials=client_call_details.credentials,
78
+ wait_for_ready=client_call_details.wait_for_ready,
75
79
  )
76
80
  rpc_call = await continuation(call_details, request)
77
81
  try:
@@ -98,11 +102,11 @@ class RetryInterceptor(UnaryUnaryClientInterceptor):
98
102
 
99
103
 
100
104
  def is_retryable(err: CadenceRpcError, call_details: ClientCallDetails) -> bool:
101
- # Handle requests to the passive side, matching the Go and Java Clients
102
- if (
103
- call_details.method == GET_WORKFLOW_HISTORY # type: ignore[comparison-overlap]
104
- and isinstance(err, EntityNotExistsError)
105
- ):
105
+ # Handle requests to the passive side, matching the Go and Java Clients.
106
+ # grpc-stubs types method as str, but grpcio corrected it to bytes in v1.75.0
107
+ # (grpc/grpc#39405). Cast to bytes to match the actual runtime type.
108
+ method = cast(bytes, call_details.method)
109
+ if method == GET_WORKFLOW_HISTORY and isinstance(err, EntityNotExistsError):
106
110
  return (
107
111
  err.active_cluster is not None
108
112
  and err.current_cluster is not None
@@ -1,4 +1,4 @@
1
- from typing import Any, Callable, cast
1
+ from typing import Any, Callable
2
2
 
3
3
  from grpc.aio import Metadata
4
4
  from grpc.aio import UnaryUnaryClientInterceptor, ClientCallDetails
@@ -38,11 +38,10 @@ class YarpcMetadataInterceptor(UnaryUnaryClientInterceptor):
38
38
  else:
39
39
  metadata += self._metadata
40
40
 
41
- # Namedtuple methods start with an underscore to avoid conflicts and aren't actually private
42
- # noinspection PyProtectedMember
43
- return cast(
44
- ClientCallDetails,
45
- client_call_details._replace( # type: ignore[attr-defined]
46
- metadata=metadata, timeout=client_call_details.timeout or 60.0
47
- ),
41
+ return ClientCallDetails(
42
+ method=client_call_details.method,
43
+ timeout=client_call_details.timeout or 60.0,
44
+ metadata=metadata,
45
+ credentials=client_call_details.credentials,
46
+ wait_for_ready=client_call_details.wait_for_ready,
48
47
  )
@@ -25,6 +25,11 @@ from cadence.api.v1.history_pb2 import (
25
25
  WorkflowExecutionSignaledEventAttributes,
26
26
  WorkflowExecutionStartedEventAttributes,
27
27
  )
28
+ from cadence.api.v1.query_pb2 import (
29
+ WorkflowQuery,
30
+ WorkflowQueryResult,
31
+ QUERY_RESULT_TYPE_ANSWERED,
32
+ )
28
33
  from cadence.api.v1.tasklist_pb2 import TaskList
29
34
  from cadence.error import ContinueAsNewError
30
35
  from cadence.workflow import WorkflowDefinition, WorkflowInfo
@@ -35,6 +40,7 @@ logger = logging.getLogger(__name__)
35
40
  @dataclass
36
41
  class DecisionResult:
37
42
  decisions: list[Decision]
43
+ query_result: Optional[WorkflowQueryResult] = None
38
44
 
39
45
 
40
46
  class WorkflowEngine:
@@ -51,6 +57,7 @@ class WorkflowEngine:
51
57
  def process_decision(
52
58
  self,
53
59
  events: List[HistoryEvent],
60
+ query: Optional[WorkflowQuery] = None,
54
61
  ) -> DecisionResult:
55
62
  """
56
63
  Process a decision task and generate decisions using DecisionEventsIterator.
@@ -59,7 +66,7 @@ class WorkflowEngine:
59
66
  to drive the decision processing pipeline with proper replay handling.
60
67
 
61
68
  Args:
62
- decision_task: The PollForDecisionTaskResponse from the service
69
+ events: The workflow history events.
63
70
 
64
71
  Returns:
65
72
  DecisionResult containing the list of decisions
@@ -74,6 +81,7 @@ class WorkflowEngine:
74
81
  "workflow_type": ctx.info().workflow_type,
75
82
  "workflow_id": ctx.info().workflow_id,
76
83
  "run_id": ctx.info().workflow_run_id,
84
+ "query": query.query_type if query else None,
77
85
  },
78
86
  )
79
87
 
@@ -83,11 +91,15 @@ class WorkflowEngine:
83
91
  # Process decision events using iterator-driven approach
84
92
  self._process_decision_events(ctx, events_iterator)
85
93
 
94
+ if query:
95
+ return self._execute_query(query)
96
+
86
97
  # Collect all pending decisions from state machines
87
98
  decisions = self._decision_manager.collect_pending_decisions()
88
99
 
89
- return DecisionResult(decisions=decisions)
100
+ return DecisionResult(decisions=decisions, query_result=None)
90
101
 
102
+ # TODO: reevaluate if this is needed to log error here or in the caller
91
103
  except Exception as e:
92
104
  # Log decision task failure with full context (matches Java ReplayDecisionTaskHandler)
93
105
  logger.error(
@@ -103,6 +115,18 @@ class WorkflowEngine:
103
115
  # Re-raise the exception so the handler can properly handle the failure
104
116
  raise
105
117
 
118
+ def _execute_query(self, query: WorkflowQuery) -> DecisionResult:
119
+ result_payload = self._workflow_instance.handle_query(
120
+ query.query_type, query.query_args
121
+ )
122
+ return DecisionResult(
123
+ decisions=[],
124
+ query_result=WorkflowQueryResult(
125
+ result_type=QUERY_RESULT_TYPE_ANSWERED,
126
+ answer=result_payload,
127
+ ),
128
+ )
129
+
106
130
  def is_done(self) -> bool:
107
131
  return self._workflow_instance.is_done()
108
132
 
@@ -96,6 +96,39 @@ class WorkflowInstance:
96
96
  if inspect.iscoroutine(result):
97
97
  await result
98
98
 
99
+ def handle_query(self, query_type: str, query_args: Payload) -> Payload:
100
+ """Execute a query handler and return the serialized result.
101
+
102
+ The query runs synchronously against the current workflow state
103
+ (after replay has caught up). It must not mutate state.
104
+
105
+ Args:
106
+ query_type: The registered query type name.
107
+ query_args: Serialized query arguments.
108
+
109
+ Returns:
110
+ Serialized query result as a Payload.
111
+
112
+ Raises:
113
+ ValueError: If the query type is not registered.
114
+ Exception: If the query handler raises.
115
+ """
116
+ query_def = self._definition.queries.get(query_type)
117
+ if query_def is None:
118
+ raise ValueError(
119
+ f"Unknown query type '{query_type}'. "
120
+ f"Known types: {list(self._definition.queries.keys())}"
121
+ )
122
+
123
+ args = query_def.params_from_payload(self._data_converter, query_args)
124
+ result = query_def(self._instance, *args)
125
+ if inspect.iscoroutine(result):
126
+ result.close()
127
+ raise TypeError(
128
+ f"Query handler '{query_type}' must be synchronous, got async function"
129
+ )
130
+ return self._data_converter.to_data([result])
131
+
99
132
  def _on_signal_task_done(self, task: Task[Any], signal_name: str) -> None:
100
133
  self._signal_tasks.discard(task)
101
134
  if task.cancelled():