dex-python-sdk 0.1.1__tar.gz → 0.1.3__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 (74) hide show
  1. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/PKG-INFO +71 -3
  2. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/README.md +70 -2
  3. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/__init__.py +20 -4
  4. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_async_worker_dispatcher.py +59 -43
  5. dex_python_sdk-0.1.3/dex/_grpc_errors.py +145 -0
  6. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_invocation_context.py +36 -1
  7. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_value_mapper.py +33 -0
  8. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_worker_dispatcher.py +75 -57
  9. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/async_client.py +453 -30
  10. dex_python_sdk-0.1.3/dex/async_worker.py +189 -0
  11. dex_python_sdk-0.1.3/dex/attribute.py +265 -0
  12. dex_python_sdk-0.1.3/dex/blob_cache.py +145 -0
  13. dex_python_sdk-0.1.3/dex/channel.py +364 -0
  14. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/client.py +443 -24
  15. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/client_options.py +9 -0
  16. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/codec.py +143 -4
  17. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/condition.py +17 -0
  18. dex_python_sdk-0.1.3/dex/context.py +179 -0
  19. dex_python_sdk-0.1.3/dex/dexpb/dex_pb2.py +395 -0
  20. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/dexpb/dex_pb2.pyi +82 -31
  21. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/dexpb/dex_pb2_grpc.py +43 -0
  22. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/flow.py +216 -23
  23. dex_python_sdk-0.1.3/dex/flow_config.py +59 -0
  24. dex_python_sdk-0.1.3/dex/flow_info.py +107 -0
  25. dex_python_sdk-0.1.3/dex/flow_options.py +214 -0
  26. dex_python_sdk-0.1.3/dex/runtime_errors.py +285 -0
  27. dex_python_sdk-0.1.3/dex/step.py +427 -0
  28. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/step_execution.py +36 -0
  29. dex_python_sdk-0.1.3/dex/timer.py +44 -0
  30. dex_python_sdk-0.1.3/dex/wait.py +108 -0
  31. dex_python_sdk-0.1.3/dex/worker.py +214 -0
  32. dex_python_sdk-0.1.3/dex/worker_options.py +52 -0
  33. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/pyproject.toml +1 -1
  34. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/Cargo.lock +140 -0
  35. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/Cargo.toml +5 -0
  36. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/src/config.rs +31 -0
  37. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/src/error.rs +11 -0
  38. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/src/lib.rs +76 -0
  39. dex_python_sdk-0.1.1/dex/_grpc_errors.py +0 -86
  40. dex_python_sdk-0.1.1/dex/async_worker.py +0 -106
  41. dex_python_sdk-0.1.1/dex/attribute.py +0 -87
  42. dex_python_sdk-0.1.1/dex/blob_cache.py +0 -73
  43. dex_python_sdk-0.1.1/dex/channel.py +0 -165
  44. dex_python_sdk-0.1.1/dex/context.py +0 -86
  45. dex_python_sdk-0.1.1/dex/dexpb/dex_pb2.py +0 -381
  46. dex_python_sdk-0.1.1/dex/flow_config.py +0 -29
  47. dex_python_sdk-0.1.1/dex/flow_info.py +0 -57
  48. dex_python_sdk-0.1.1/dex/flow_options.py +0 -122
  49. dex_python_sdk-0.1.1/dex/runtime_errors.py +0 -81
  50. dex_python_sdk-0.1.1/dex/step.py +0 -194
  51. dex_python_sdk-0.1.1/dex/timer.py +0 -21
  52. dex_python_sdk-0.1.1/dex/wait.py +0 -49
  53. dex_python_sdk-0.1.1/dex/worker.py +0 -121
  54. dex_python_sdk-0.1.1/dex/worker_options.py +0 -22
  55. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/LEGACY_NOTICES.md +0 -0
  56. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/LICENSE +0 -0
  57. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_async_value_hydrator.py +0 -0
  58. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_async_worker_service.py +0 -0
  59. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_native.pyi +0 -0
  60. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_utils.py +0 -0
  61. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_value_hydrator.py +0 -0
  62. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/_worker_service.py +0 -0
  63. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/dexpb/__init__.py +0 -0
  64. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/dex/py.typed +0 -0
  65. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  66. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  67. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  68. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  69. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  70. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  71. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  72. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  73. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  74. {dex_python_sdk-0.1.1 → dex_python_sdk-0.1.3}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dex-python-sdk
3
- Version: 0.1.1
3
+ Version: 0.1.3
4
4
  Requires-Dist: grpcio>=1.83.0
5
5
  Requires-Dist: grpcio-status>=1.83.0
6
6
  Requires-Dist: protobuf>=7.35.1
@@ -39,7 +39,7 @@ class Run(dex.Step[str]):
39
39
  def wait_for(
40
40
  self, context: dex.Context, input: str
41
41
  ) -> dex.Wait:
42
- return dex.Wait.all_of(
42
+ return dex.Wait.until(
43
43
  dex.Timer.by_duration(timedelta(seconds=1))
44
44
  )
45
45
 
@@ -76,6 +76,11 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
76
76
  `PersistenceSchema.of(...)` accepts attributes and channels together and
77
77
  partitions them by definition type.
78
78
 
79
+ `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
80
+ Dex Server before opening their listener. Existing indexes return immediately;
81
+ failure or the default two-minute deadline aborts startup. An indexed
82
+ `AttributeMap` must provide one fixed `index_key`.
83
+
79
84
  Initial attributes retain their value types without a public wrapper class:
80
85
 
81
86
  ```python
@@ -86,6 +91,19 @@ options = (
86
91
  )
87
92
  ```
88
93
 
94
+ Opt in when declaring an Attribute or AttributeMap, and select the Store in
95
+ Flow configuration:
96
+
97
+ ```python
98
+ email = dex.Attribute("customer-email", str, sync_to_attribute_store=True)
99
+ config = dex.FlowConfig(attribute_store_name="profiles")
100
+ ```
101
+
102
+ The Store is an asynchronous latest-state projection. Deletion writes SQL
103
+ `NULL`, and projection failures do not roll back Flow Attributes. `None`
104
+ preserves the current target; an explicit empty string disables future
105
+ synchronization while retaining protocol presence.
106
+
89
107
  ```
90
108
  pip install dex-python-sdk==0.1.0
91
109
  ```
@@ -117,6 +135,45 @@ two handler calls. Timer and channel conditions determine how long a Step waits.
117
135
  codec before Client or Worker startup. `Client` methods use these typed objects
118
136
  instead of raw Flow, Step, or RPC strings.
119
137
 
138
+ ### Waiting and map inspection
139
+
140
+ `Wait.all_of` and `Wait.any_of` may use unnamed Conditions. Every Condition in
141
+ `Wait.any_combination_of` must have a non-empty user ID; the same Condition
142
+ instance may appear in multiple combinations.
143
+
144
+ Both `Client` and `AsyncClient` provide `wait_for_attribute_equal` and
145
+ `wait_for_attribute_map_equal`. They target the current run and accept only
146
+ string, bool, int, or float wire values. JSON objects, bytes, and null fail
147
+ before transport. `AttributeMap.get_map_size/get_all_instance_keys` include
148
+ buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
149
+ include buffered publishes, and omit empty instances. Keys are decoded and
150
+ sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
151
+
152
+ ### Errors
153
+
154
+ Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
155
+ (`get_attribute`, `describe_flow`, `wait_for_flow`, and `reset_flow`) raise
156
+ `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
157
+ waits, config updates, and continue-as-new triggers raise
158
+ `FlowNotActiveError` when no running Flow can accept the operation.
159
+
160
+ ```python
161
+ try:
162
+ client.publish(flow_id, orders.approved, order_id)
163
+ except dex.FlowNotActiveError:
164
+ # The Flow is missing or already closed.
165
+ pass
166
+ ```
167
+
168
+ Duplicate starts, worker failures, RPC lock contention, and long-poll timeouts
169
+ raise `FlowAlreadyStartedError`, `WorkerInvocationError`,
170
+ `RpcLockConflictError`, and `LongPollTimeoutError`. All service errors retain
171
+ `code`, `sub_status`, `detail`, `operation`, `flow_id`, and the original gRPC
172
+ exception through Python exception chaining. Worker failures also expose
173
+ `worker_code`, `worker_error_type`, and `worker_error_detail`. Registration,
174
+ serialization, and invalid handler returns use `FlowDefinitionError`,
175
+ `ValueMappingError`, and `InvalidStepResultError`.
176
+
120
177
  ### Sync vs asyncio
121
178
 
122
179
  - **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
@@ -209,7 +266,18 @@ make -C ../protos proto-python
209
266
  Checked-in Python stubs land in `dex/dexpb/`.
210
267
  #### Linting
211
268
 
212
- To run linting for this project:
269
+ Validate that every `dex.__all__` class, function, constant, public method,
270
+ argument, return value, dataclass field, enum value, and public instance
271
+ attribute has a Google-style docstring:
272
+
273
+ ```bash
274
+ uv run --frozen python scripts/check_public_docs.py
275
+ ```
276
+
277
+ The checker resolves definitions from the public package export table, so
278
+ private helpers and generated protobuf modules are excluded. Use `help(dex.Client)`
279
+ or IDE hover information to read the same documentation. To run all other
280
+ linting for this project:
213
281
 
214
282
  ```bash
215
283
  uv run --frozen pre-commit run --show-diff-on-failure --color=always --all-files
@@ -23,7 +23,7 @@ class Run(dex.Step[str]):
23
23
  def wait_for(
24
24
  self, context: dex.Context, input: str
25
25
  ) -> dex.Wait:
26
- return dex.Wait.all_of(
26
+ return dex.Wait.until(
27
27
  dex.Timer.by_duration(timedelta(seconds=1))
28
28
  )
29
29
 
@@ -60,6 +60,11 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
60
60
  `PersistenceSchema.of(...)` accepts attributes and channels together and
61
61
  partitions them by definition type.
62
62
 
63
+ `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
64
+ Dex Server before opening their listener. Existing indexes return immediately;
65
+ failure or the default two-minute deadline aborts startup. An indexed
66
+ `AttributeMap` must provide one fixed `index_key`.
67
+
63
68
  Initial attributes retain their value types without a public wrapper class:
64
69
 
65
70
  ```python
@@ -70,6 +75,19 @@ options = (
70
75
  )
71
76
  ```
72
77
 
78
+ Opt in when declaring an Attribute or AttributeMap, and select the Store in
79
+ Flow configuration:
80
+
81
+ ```python
82
+ email = dex.Attribute("customer-email", str, sync_to_attribute_store=True)
83
+ config = dex.FlowConfig(attribute_store_name="profiles")
84
+ ```
85
+
86
+ The Store is an asynchronous latest-state projection. Deletion writes SQL
87
+ `NULL`, and projection failures do not roll back Flow Attributes. `None`
88
+ preserves the current target; an explicit empty string disables future
89
+ synchronization while retaining protocol presence.
90
+
73
91
  ```
74
92
  pip install dex-python-sdk==0.1.0
75
93
  ```
@@ -101,6 +119,45 @@ two handler calls. Timer and channel conditions determine how long a Step waits.
101
119
  codec before Client or Worker startup. `Client` methods use these typed objects
102
120
  instead of raw Flow, Step, or RPC strings.
103
121
 
122
+ ### Waiting and map inspection
123
+
124
+ `Wait.all_of` and `Wait.any_of` may use unnamed Conditions. Every Condition in
125
+ `Wait.any_combination_of` must have a non-empty user ID; the same Condition
126
+ instance may appear in multiple combinations.
127
+
128
+ Both `Client` and `AsyncClient` provide `wait_for_attribute_equal` and
129
+ `wait_for_attribute_map_equal`. They target the current run and accept only
130
+ string, bool, int, or float wire values. JSON objects, bytes, and null fail
131
+ before transport. `AttributeMap.get_map_size/get_all_instance_keys` include
132
+ buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
133
+ include buffered publishes, and omit empty instances. Keys are decoded and
134
+ sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
135
+
136
+ ### Errors
137
+
138
+ Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
139
+ (`get_attribute`, `describe_flow`, `wait_for_flow`, and `reset_flow`) raise
140
+ `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
141
+ waits, config updates, and continue-as-new triggers raise
142
+ `FlowNotActiveError` when no running Flow can accept the operation.
143
+
144
+ ```python
145
+ try:
146
+ client.publish(flow_id, orders.approved, order_id)
147
+ except dex.FlowNotActiveError:
148
+ # The Flow is missing or already closed.
149
+ pass
150
+ ```
151
+
152
+ Duplicate starts, worker failures, RPC lock contention, and long-poll timeouts
153
+ raise `FlowAlreadyStartedError`, `WorkerInvocationError`,
154
+ `RpcLockConflictError`, and `LongPollTimeoutError`. All service errors retain
155
+ `code`, `sub_status`, `detail`, `operation`, `flow_id`, and the original gRPC
156
+ exception through Python exception chaining. Worker failures also expose
157
+ `worker_code`, `worker_error_type`, and `worker_error_detail`. Registration,
158
+ serialization, and invalid handler returns use `FlowDefinitionError`,
159
+ `ValueMappingError`, and `InvalidStepResultError`.
160
+
104
161
  ### Sync vs asyncio
105
162
 
106
163
  - **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
@@ -193,7 +250,18 @@ make -C ../protos proto-python
193
250
  Checked-in Python stubs land in `dex/dexpb/`.
194
251
  #### Linting
195
252
 
196
- To run linting for this project:
253
+ Validate that every `dex.__all__` class, function, constant, public method,
254
+ argument, return value, dataclass field, enum value, and public instance
255
+ attribute has a Google-style docstring:
256
+
257
+ ```bash
258
+ uv run --frozen python scripts/check_public_docs.py
259
+ ```
260
+
261
+ The checker resolves definitions from the public package export table, so
262
+ private helpers and generated protobuf modules are excluded. Use `help(dex.Client)`
263
+ or IDE hover information to read the same documentation. To run all other
264
+ linting for this project:
197
265
 
198
266
  ```bash
199
267
  uv run --frozen pre-commit run --show-diff-on-failure --color=always --all-files
@@ -53,11 +53,19 @@ from dex.flow_options import (
53
53
  StopType,
54
54
  )
55
55
  from dex.runtime_errors import (
56
- DexException,
56
+ DexServiceError,
57
57
  ErrorSubStatus,
58
+ FlowAlreadyStartedError,
59
+ FlowDefinitionError,
58
60
  FlowErrorType,
61
+ FlowNotActiveError,
62
+ FlowNotFoundError,
59
63
  FlowUncompletedError,
64
+ InvalidStepResultError,
60
65
  LongPollTimeoutError,
66
+ RpcLockConflictError,
67
+ ValueMappingError,
68
+ WorkerInvocationError,
61
69
  )
62
70
  from dex.step import (
63
71
  RetryPolicy,
@@ -70,7 +78,7 @@ from dex.step import (
70
78
  WaitForFailurePolicy,
71
79
  dead_end,
72
80
  force_complete,
73
- force_complete_when_channels_empty,
81
+ force_complete_if_channels_empty,
74
82
  force_fail,
75
83
  go_to,
76
84
  go_to_multi,
@@ -105,21 +113,27 @@ __all__ = [
105
113
  "CodecRegistry",
106
114
  "ConditionCombination",
107
115
  "Context",
108
- "DexException",
116
+ "DexServiceError",
109
117
  "ErrorSubStatus",
110
118
  "Flow",
119
+ "FlowAlreadyStartedError",
111
120
  "FlowConfig",
121
+ "FlowDefinitionError",
112
122
  "FlowErrorType",
113
123
  "FlowInfo",
124
+ "FlowNotActiveError",
125
+ "FlowNotFoundError",
114
126
  "FlowStatus",
115
127
  "FlowUncompletedError",
116
128
  "HealthInfo",
117
129
  "IdReusePolicy",
130
+ "InvalidStepResultError",
118
131
  "IndexType",
119
132
  "JsonCodec",
120
133
  "LongPollTimeoutError",
121
134
  "PersistenceSchema",
122
135
  "RPCResult",
136
+ "RpcLockConflictError",
123
137
  "Registry",
124
138
  "ResetFlowOptions",
125
139
  "ResetType",
@@ -139,15 +153,17 @@ __all__ = [
139
153
  "Timer",
140
154
  "TimerId",
141
155
  "Value",
156
+ "ValueMappingError",
142
157
  "Wait",
143
158
  "WaitForFailurePolicy",
144
159
  "WireKind",
145
160
  "Worker",
161
+ "WorkerInvocationError",
146
162
  "WorkerOptions",
147
163
  "WorkerTarget",
148
164
  "dead_end",
149
165
  "force_complete",
150
- "force_complete_when_channels_empty",
166
+ "force_complete_if_channels_empty",
151
167
  "force_fail",
152
168
  "graceful_complete",
153
169
  "go_to",
@@ -17,6 +17,7 @@ from dex._value_mapper import ValueMapper
17
17
  from dex._worker_dispatcher import WorkerDispatcher
18
18
  from dex.dexpb import dex_pb2 as pb
19
19
  from dex.flow import RPCResult, Registry
20
+ from dex.runtime_errors import InvalidStepResultError, ValueMappingError
20
21
  from dex.step import StepDecision
21
22
  from dex.wait import Wait
22
23
 
@@ -30,13 +31,13 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
30
31
  ) -> None:
31
32
  self._registry = registry
32
33
  self._values = values
33
- self._hydrator = hydrator # type: ignore[assignment]
34
+ self._async_hydrator = hydrator
34
35
 
35
36
  async def invoke_wait_for( # type: ignore[override]
36
37
  self,
37
38
  original: pb.InvokeWaitForMethodRequest,
38
39
  ) -> pb.InvokeWaitForMethodResponse:
39
- request = await self._hydrator.wait_for_request(original)
40
+ request = await self._async_hydrator.wait_for_request(original)
40
41
  flow = self._registry._flow_by_type(request.flow_type)
41
42
  step = flow.step(request.step_type)
42
43
  context = InvocationContext(
@@ -50,24 +51,29 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
50
51
  wait = step.step.wait_for(context, input)
51
52
  if isawaitable(wait):
52
53
  wait = await wait
53
- if not isinstance(wait, Wait):
54
- raise TypeError("wait_for must return Wait")
55
- response = pb.InvokeWaitForMethodResponse(
56
- upsert_attributes=list(context.attribute_writes.values()),
57
- upsert_step_exe_locals=list(context.local_writes.values()),
58
- record_events=context.events,
59
- publish_to_channel=context.publications,
60
- )
61
- waiting = self._map_wait(flow, wait)
62
- if waiting is not None:
63
- response.waiting_condition.CopyFrom(waiting)
64
- return response
54
+ try:
55
+ if not isinstance(wait, Wait):
56
+ raise TypeError("wait_for must return Wait")
57
+ response = pb.InvokeWaitForMethodResponse(
58
+ upsert_attributes=list(context.attribute_writes.values()),
59
+ upsert_step_exe_locals=list(context.local_writes.values()),
60
+ record_events=context.events,
61
+ publish_to_channel=context.publications,
62
+ )
63
+ waiting = self._map_wait(flow, wait)
64
+ if waiting is not None:
65
+ response.waiting_condition.CopyFrom(waiting)
66
+ return response
67
+ except (TypeError, ValueError) as error:
68
+ raise InvalidStepResultError(
69
+ flow.name, step.name, "wait_for", str(error)
70
+ ) from error
65
71
 
66
72
  async def invoke_execute( # type: ignore[override]
67
73
  self,
68
74
  original: pb.InvokeExecuteMethodRequest,
69
75
  ) -> pb.InvokeExecuteMethodResponse:
70
- request = await self._hydrator.execute_request(original)
76
+ request = await self._async_hydrator.execute_request(original)
71
77
  flow = self._registry._flow_by_type(request.flow_type)
72
78
  step = flow.step(request.step_type)
73
79
  condition_results = (
@@ -86,21 +92,26 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
86
92
  decision: Any = step.step.execute(context, input)
87
93
  if isawaitable(decision):
88
94
  decision = await decision
89
- if not isinstance(decision, StepDecision):
90
- raise TypeError("execute must return StepDecision")
91
- return pb.InvokeExecuteMethodResponse(
92
- step_decision=self._map_decision(flow, decision),
93
- upsert_attributes=list(context.attribute_writes.values()),
94
- record_events=context.events,
95
- upsert_step_exe_locals=list(context.local_writes.values()),
96
- publish_to_channel=context.publications,
97
- )
95
+ try:
96
+ if not isinstance(decision, StepDecision):
97
+ raise TypeError("execute must return StepDecision")
98
+ return pb.InvokeExecuteMethodResponse(
99
+ step_decision=self._map_decision(flow, decision),
100
+ upsert_attributes=list(context.attribute_writes.values()),
101
+ record_events=context.events,
102
+ upsert_step_exe_locals=list(context.local_writes.values()),
103
+ publish_to_channel=context.publications,
104
+ )
105
+ except (TypeError, ValueError) as error:
106
+ raise InvalidStepResultError(
107
+ flow.name, step.name, "execute", str(error)
108
+ ) from error
98
109
 
99
110
  async def invoke_rpc( # type: ignore[override]
100
111
  self,
101
112
  original: pb.InvokeWorkerRPCRequest,
102
113
  ) -> pb.InvokeWorkerRPCResponse:
103
- request = await self._hydrator.rpc_request(original)
114
+ request = await self._async_hydrator.rpc_request(original)
104
115
  flow = self._registry._flow_by_type(request.flow_type)
105
116
  rpc = flow.rpc(request.rpc_name)
106
117
  context = InvocationContext(
@@ -117,23 +128,28 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
117
128
  returned: Any = rpc.method(*arguments)
118
129
  if isawaitable(returned):
119
130
  returned = await returned
120
- response = pb.InvokeWorkerRPCResponse(
121
- upsert_attributes=list(context.attribute_writes.values()),
122
- record_events=context.events,
123
- publish_to_channel=context.publications,
124
- )
125
- if isinstance(returned, RPCResult):
126
- if rpc.output_codec is None:
127
- raise TypeError("RPCResult requires an output type")
128
- response.output.CopyFrom(
129
- self._values.encode(returned.output, rpc.output_codec)
131
+ try:
132
+ response = pb.InvokeWorkerRPCResponse(
133
+ upsert_attributes=list(context.attribute_writes.values()),
134
+ record_events=context.events,
135
+ publish_to_channel=context.publications,
130
136
  )
131
- if returned.next_steps:
132
- response.step_decision.next_steps.extend(
133
- self._map_movements(flow, returned.next_steps)
137
+ if isinstance(returned, RPCResult):
138
+ if rpc.output_codec is None:
139
+ raise TypeError("RPCResult requires an output type")
140
+ response.output.CopyFrom(
141
+ self._values.encode(returned.output, rpc.output_codec)
134
142
  )
135
- elif returned is None and rpc.output_codec is None:
136
- response.output.CopyFrom(self._values.encode_dynamic(None))
137
- else:
138
- raise TypeError("RPC must return RPCResult or None")
139
- return response
143
+ if returned.next_steps:
144
+ response.step_decision.next_steps.extend(
145
+ self._map_movements(flow, returned.next_steps)
146
+ )
147
+ elif returned is None and rpc.output_codec is None:
148
+ response.output.CopyFrom(self._values.encode_dynamic(None))
149
+ else:
150
+ raise TypeError("RPC must return RPCResult or None")
151
+ return response
152
+ except ValueMappingError:
153
+ raise
154
+ except (TypeError, ValueError) as error:
155
+ raise InvalidStepResultError(flow.name, None, "rpc", str(error)) from error
@@ -0,0 +1,145 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Super Durable Source License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Literal, cast
12
+
13
+ import grpc
14
+ from google.protobuf import any_pb2
15
+ from google.rpc import status_pb2
16
+ from grpc_status import rpc_status
17
+
18
+ from dex.dexpb import dex_pb2 as pb
19
+ from dex.runtime_errors import (
20
+ DexServiceError,
21
+ ErrorSubStatus,
22
+ FlowAlreadyStartedError,
23
+ FlowNotActiveError,
24
+ FlowNotFoundError,
25
+ LongPollTimeoutError,
26
+ RpcLockConflictError,
27
+ WorkerInvocationError,
28
+ )
29
+
30
+ FlowTargetRequirement = Literal["none", "existing", "active"]
31
+
32
+
33
+ def translate_rpc_error(
34
+ error: grpc.RpcError,
35
+ operation: str,
36
+ flow_id: str | None,
37
+ requirement: FlowTargetRequirement,
38
+ ) -> DexServiceError:
39
+ details: pb.ErrorResponse | None = None
40
+ try:
41
+ status = rpc_status.from_call(cast(grpc.Call, error))
42
+ if status is not None:
43
+ for packed in status.details:
44
+ candidate = pb.ErrorResponse()
45
+ if packed.Is(candidate.DESCRIPTOR):
46
+ packed.Unpack(candidate)
47
+ details = candidate
48
+ break
49
+ except Exception as malformed:
50
+ translated = DexServiceError(
51
+ error.code(),
52
+ ErrorSubStatus.UNCATEGORIZED,
53
+ f"Dex returned malformed error details: {malformed}",
54
+ operation,
55
+ flow_id,
56
+ )
57
+ return translated
58
+ detail = (
59
+ details.detail if details is not None and details.detail else error.details()
60
+ ) or str(error)
61
+ code = error.code()
62
+ sub_status = (
63
+ _map_sub_status(details.sub_status)
64
+ if details is not None
65
+ else ErrorSubStatus.UNCATEGORIZED
66
+ )
67
+ parameters = (code, sub_status, detail, operation, flow_id)
68
+ if sub_status is ErrorSubStatus.FLOW_ALREADY_STARTED:
69
+ return FlowAlreadyStartedError(*parameters)
70
+ if sub_status is ErrorSubStatus.FLOW_NOT_EXISTS:
71
+ if requirement == "existing":
72
+ return FlowNotFoundError(*parameters)
73
+ if requirement == "active":
74
+ return FlowNotActiveError(*parameters)
75
+ return DexServiceError(*parameters)
76
+ if sub_status is ErrorSubStatus.WORKER_API_ERROR:
77
+ if code is grpc.StatusCode.ABORTED:
78
+ return RpcLockConflictError(*parameters)
79
+ return WorkerInvocationError(
80
+ *parameters,
81
+ _worker_code(details),
82
+ details.original_worker_error_type if details is not None else "",
83
+ details.original_worker_error_detail if details is not None else "",
84
+ )
85
+ if sub_status is ErrorSubStatus.LONG_POLL_TIMEOUT:
86
+ return LongPollTimeoutError(*parameters)
87
+ return DexServiceError(*parameters)
88
+
89
+
90
+ def _worker_code(details: pb.ErrorResponse | None) -> grpc.StatusCode | None:
91
+ if details is None or details.original_worker_error_status == 0:
92
+ return None
93
+ return next(
94
+ (
95
+ code
96
+ for code in grpc.StatusCode
97
+ if code.value[0] == details.original_worker_error_status
98
+ ),
99
+ grpc.StatusCode.UNKNOWN,
100
+ )
101
+
102
+
103
+ def abort_worker_error(
104
+ context: grpc.ServicerContext,
105
+ error: BaseException,
106
+ ) -> None:
107
+ context.abort_with_status(rpc_status.to_status(_worker_error_status(error)))
108
+
109
+
110
+ async def async_abort_worker_error(
111
+ context: grpc.aio.ServicerContext[Any, Any],
112
+ error: BaseException,
113
+ ) -> None:
114
+ # types-grpcio on 3.11 omits aio abort_with_status; runtime provides it.
115
+ await cast(Any, context).abort_with_status(
116
+ rpc_status.to_status(_worker_error_status(error))
117
+ )
118
+
119
+
120
+ def _worker_error_status(error: BaseException) -> status_pb2.Status:
121
+ message = str(error) or type(error).__name__
122
+ worker_error = pb.WorkerErrorResponse(
123
+ detail=message,
124
+ error_type=f"{type(error).__module__}.{type(error).__qualname__}",
125
+ )
126
+ packed = any_pb2.Any()
127
+ packed.Pack(worker_error)
128
+ return status_pb2.Status(
129
+ code=grpc.StatusCode.UNKNOWN.value[0],
130
+ message=message,
131
+ details=[packed],
132
+ )
133
+
134
+
135
+ def _map_sub_status(value: int) -> ErrorSubStatus:
136
+ statuses: dict[int, ErrorSubStatus] = {
137
+ int(pb.ERROR_SUB_STATUS_UNCATEGORIZED): ErrorSubStatus.UNCATEGORIZED,
138
+ int(
139
+ pb.ERROR_SUB_STATUS_FLOW_ALREADY_STARTED
140
+ ): ErrorSubStatus.FLOW_ALREADY_STARTED,
141
+ int(pb.ERROR_SUB_STATUS_FLOW_NOT_EXISTS): ErrorSubStatus.FLOW_NOT_EXISTS,
142
+ int(pb.ERROR_SUB_STATUS_WORKER_API_ERROR): ErrorSubStatus.WORKER_API_ERROR,
143
+ int(pb.ERROR_SUB_STATUS_LONG_POLL_TIME_OUT): ErrorSubStatus.LONG_POLL_TIMEOUT,
144
+ }
145
+ return statuses.get(value, ErrorSubStatus.UNCATEGORIZED)
@@ -10,10 +10,11 @@ from __future__ import annotations
10
10
 
11
11
  from enum import Enum
12
12
  from typing import Any, Sequence, TypeVar, cast
13
+ from urllib.parse import unquote
13
14
 
14
15
  from dex._utils import require_name
15
16
  from dex._value_mapper import ValueMapper
16
- from dex.attribute import Attribute, AttributeMap
17
+ from dex.attribute import Attribute, AttributeMap, _apply_attribute_store_sync
17
18
  from dex.channel import Channel, ChannelMap
18
19
  from dex.codec import Codec
19
20
  from dex.dexpb import dex_pb2 as pb
@@ -161,6 +162,7 @@ class InvocationContext:
161
162
  )
162
163
  if index is not None:
163
164
  write.index_config.CopyFrom(index)
165
+ _apply_attribute_store_sync(write, definition)
164
166
  self.attribute_writes[key] = write
165
167
 
166
168
  def _delete_attribute(
@@ -177,6 +179,7 @@ class InvocationContext:
177
179
  )
178
180
  if index is not None:
179
181
  write.index_config.CopyFrom(index)
182
+ _apply_attribute_store_sync(write, definition)
180
183
  self.attribute_writes[key] = write
181
184
 
182
185
  def _publish_channel(
@@ -202,6 +205,38 @@ class InvocationContext:
202
205
  size=(current.size if current is not None else 0) + 1
203
206
  )
204
207
 
208
+ def _attribute_map_keys(
209
+ self,
210
+ definition: AttributeMap[object],
211
+ ) -> tuple[str, ...]:
212
+ self._require_registered(definition)
213
+ prefix = f"{definition.name}/"
214
+ physical_keys = {key for key in self._attributes if key.startswith(prefix)}
215
+ for key, write in self.attribute_writes.items():
216
+ if not key.startswith(prefix):
217
+ continue
218
+ if write.value.WhichOneof("kind") == "null_value":
219
+ physical_keys.discard(key)
220
+ else:
221
+ physical_keys.add(key)
222
+ return tuple(sorted(unquote(key[len(prefix) :]) for key in physical_keys))
223
+
224
+ def _channel_map_keys(
225
+ self,
226
+ definition: ChannelMap[object],
227
+ ) -> tuple[str, ...]:
228
+ self._require_registered(definition)
229
+ if self._method is not InvocationMethod.RPC:
230
+ raise ValueError("ChannelMap introspection requires an RPC invocation")
231
+ prefix = f"{definition.name}/"
232
+ return tuple(
233
+ sorted(
234
+ unquote(key[len(prefix) :])
235
+ for key, info in self._channel_infos.items()
236
+ if key.startswith(prefix) and info.size > 0
237
+ )
238
+ )
239
+
205
240
  def _channel_size(
206
241
  self,
207
242
  definition: Channel[object] | ChannelMap[object],