dex-python-sdk 0.1.3__tar.gz → 0.1.5__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 (61) hide show
  1. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/PKG-INFO +115 -19
  2. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/README.md +114 -18
  3. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/__init__.py +19 -8
  4. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_async_value_hydrator.py +4 -2
  5. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_async_worker_dispatcher.py +62 -3
  6. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_async_worker_service.py +44 -11
  7. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_grpc_errors.py +3 -3
  8. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_invocation_context.py +31 -1
  9. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_value_hydrator.py +11 -2
  10. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_worker_dispatcher.py +274 -12
  11. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_worker_service.py +17 -3
  12. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/async_client.py +138 -74
  13. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/client.py +138 -73
  14. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/codec.py +15 -13
  15. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/condition.py +21 -1
  16. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/context.py +13 -1
  17. dex_python_sdk-0.1.5/dex/dexpb/dex_pb2.py +441 -0
  18. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/dexpb/dex_pb2.pyi +319 -73
  19. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/dexpb/dex_pb2_grpc.py +24 -21
  20. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/flow.py +65 -2
  21. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/flow_info.py +2 -2
  22. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/flow_options.py +154 -16
  23. dex_python_sdk-0.1.5/dex/flow_result.py +215 -0
  24. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/runtime_errors.py +3 -64
  25. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/step.py +78 -6
  26. dex_python_sdk-0.1.5/dex/subflow.py +85 -0
  27. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/pyproject.toml +1 -1
  28. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/Cargo.toml +3 -1
  29. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/Cargo.toml +1 -0
  30. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +2 -1
  31. dex_python_sdk-0.1.3/dex/dexpb/dex_pb2.py +0 -395
  32. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/LEGACY_NOTICES.md +0 -0
  33. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/LICENSE +0 -0
  34. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_native.pyi +0 -0
  35. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_utils.py +0 -0
  36. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_value_mapper.py +0 -0
  37. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/async_worker.py +0 -0
  38. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/attribute.py +0 -0
  39. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/blob_cache.py +0 -0
  40. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/channel.py +0 -0
  41. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/client_options.py +0 -0
  42. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/dexpb/__init__.py +0 -0
  43. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/flow_config.py +0 -0
  44. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/py.typed +0 -0
  45. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/step_execution.py +0 -0
  46. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/timer.py +0 -0
  47. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/wait.py +0 -0
  48. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/worker.py +0 -0
  49. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/worker_options.py +0 -0
  50. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/Cargo.lock +0 -0
  51. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  60. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  61. {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/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.3
3
+ Version: 0.1.5
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
@@ -71,7 +71,7 @@ registry = dex.Registry((flow,))
71
71
  ```
72
72
 
73
73
  Registry derives codecs from declared Python types and handler annotations.
74
- Built-in scalar types and dataclasses need no codec arguments. Register an
74
+ Built-in primitive types and dataclasses need no codec arguments. Register an
75
75
  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.
@@ -131,6 +131,71 @@ Applications implement two generic interfaces from [`dex`](dex/):
131
131
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
132
132
  two handler calls. Timer and channel conditions determine how long a Step waits.
133
133
 
134
+ `wait_for_retry` and `execute_retry` limit one logical handler execution. With
135
+ `StepDurability.ASYNC`, local and fallback regular activities share maximum
136
+ attempts, total duration, and 1-based attempt numbers. Fallback starts
137
+ immediately; later regular retries continue the backoff sequence at the
138
+ cumulative attempt.
139
+
140
+ ### Canceling Step executions
141
+
142
+ A successful Step can cancel queued or active executions while continuing with
143
+ its normal decision:
144
+
145
+ ```python
146
+ return (
147
+ dex.go_to(self.record_quote, quote)
148
+ .with_canceling_sibling_steps(self.carrier_a, self.carrier_b)
149
+ .with_canceling_steps(self.global_quote_timeout)
150
+ )
151
+ ```
152
+
153
+ `with_canceling_steps` selects every current execution of each registered Step
154
+ type. `with_canceling_sibling_steps` selects only executions with the same
155
+ `Context.from_step_execution_id` as the current execution. Decisions are
156
+ immutable; repeated calls form a union, and Flow-wide selection wins for the
157
+ same Step type. Unregistered selectors produce an invalid Step result.
158
+
159
+ Dex resolves one snapshot after the current execution succeeds. Completed,
160
+ already-canceled, and absent targets are no-ops. Next Steps created by the same
161
+ decision are outside the snapshot. Dex immediately applies the next or close
162
+ action; late decisions, writes, retries, and recovery Steps are discarded.
163
+
164
+ Set `StepOptions.heartbeat_timeout` on long-running regular Steps so
165
+ cancellation reaches the Worker promptly. It applies to `wait_for` and
166
+ `execute`; local activities ignore it, while an ASYNC fallback uses it. `None`
167
+ and zero disable heartbeats, and positive values must be whole seconds in the
168
+ signed int32 range. `AsyncWorker` cancels the handler's asyncio task. A handler
169
+ may catch `asyncio.CancelledError` for cleanup; synchronous CPU-bound handlers
170
+ may check `Context.is_cancellation_requested()` at natural boundaries.
171
+
172
+ `RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
173
+ RPCs do not support sibling selection because they have no Step execution
174
+ lineage.
175
+
176
+ ### Soft Flow timeout
177
+
178
+ Override `Flow.handle_timeout` to make a positive timeout use handler policy
179
+ by default. Both synchronous and async Workers support the hook:
180
+
181
+ ```python
182
+ class Orders(dex.Flow[str]):
183
+ async def handle_timeout(self, context: dex.Context) -> dex.StepDecision:
184
+ await notify_expiration(context)
185
+ return dex.force_complete("expired")
186
+
187
+ options = dex.StartFlowOptions(
188
+ timeout=timedelta(minutes=30),
189
+ timeout_policy=dex.FlowTimeoutPolicy.HANDLER,
190
+ )
191
+ ```
192
+
193
+ Register async hooks with `allow_async_handlers=True` and run them with
194
+ `AsyncWorker`. `FAIL` produces `FlowErrorType.FLOW_TIMEOUT` and permits Flow
195
+ retry; `CANCEL` cancels without retry. Continue-as-new preserves the deadline,
196
+ while retry and cron runs receive a fresh budget. A zero or absent timeout
197
+ disables the feature.
198
+
134
199
  `Registry` validates every Flow, Step, RPC signature, durable name, lock, and
135
200
  codec before Client or Worker startup. `Client` methods use these typed objects
136
201
  instead of raw Flow, Step, or RPC strings.
@@ -141,18 +206,54 @@ instead of raw Flow, Step, or RPC strings.
141
206
  `Wait.any_combination_of` must have a non-empty user ID; the same Condition
142
207
  instance may appear in multiple combinations.
143
208
 
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
209
+ Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
210
+ overloads of `wait_for_attribute_equal`. They target the current run and accept
211
+ only string, bool, int, or float wire values. JSON objects, bytes, and null fail
147
212
  before transport. `AttributeMap.get_map_size/get_all_instance_keys` include
148
213
  buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
149
214
  include buffered publishes, and omit empty instances. Keys are decoded and
150
215
  sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
151
216
 
217
+ `Client.wait_for_flow` and `AsyncClient.wait_for_flow` return a
218
+ `FlowResult` after hydrating every output-bearing completion. Use
219
+ `single_output` only when the Flow contract produces exactly one output:
220
+
221
+ ```python
222
+ output = client.wait_for_flow(flow_id).single_output(OrderResult)
223
+
224
+ result = client.wait_for_flow(flow_id)
225
+ for completion in result.completions:
226
+ if completion.step_execution_id == expected_execution_id:
227
+ output = completion.decode(OrderResult)
228
+ ```
229
+
230
+ `completions` is an immutable tuple in server collection order. Parallel branch
231
+ order is not deterministic, so select by `step_type` or `step_execution_id`.
232
+ No-output Flows return an empty tuple; `single_output` raises `ValueError` for
233
+ zero or multiple completions. Every terminal status returns a `FlowResult`; inspect
234
+ `status`, `error_type`, and `error_message` for unsuccessful completion.
235
+
236
+ SubFlows are normal, independently addressable Flows used as durable Conditions:
237
+
238
+ ```python
239
+ def wait_for(self, context: Context, input: ChargeInput) -> Wait:
240
+ return Wait.until(SubFlow.run(self.charge_flow, input))
241
+
242
+ def execute(self, context: Context, input: ChargeInput) -> StepDecision:
243
+ del input
244
+ receipt = SubFlow.get_condition_results(context).single_output(Receipt)
245
+ return graceful_complete(receipt)
246
+ ```
247
+
248
+ `SubFlow.get_flow_id(context, index=0)` remains available for a running `any_of`
249
+ loser. `SubFlowOptions` configures timing, timeout policy, retry, initial target
250
+ Attributes, Flow config, Condition ID, and reuse. Parent completion does not cancel an unfinished
251
+ SubFlow.
252
+
152
253
  ### Errors
153
254
 
154
255
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
155
- (`get_attribute`, `describe_flow`, `wait_for_flow`, and `reset_flow`) raise
256
+ (`get_attribute`, `describe_flow`, `wait_for_flow`, and `time_travel`) raise
156
257
  `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
157
258
  waits, config updates, and continue-as-new triggers raise
158
259
  `FlowNotActiveError` when no running Flow can accept the operation.
@@ -199,22 +300,17 @@ implemented. Python owns its gRPC transport; the native bridge is limited to
199
300
  the shared BlobCache. Design notes:
200
301
  [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
201
302
 
202
- ## Running dex-server locally
303
+ ## Running Dex locally
203
304
 
204
- ### Option 1: use docker compose
205
- See [dex README](https://github.com/superdurable/dex#using-docker-image--docker-compose)
305
+ Install and start the complete local environment with `dexcli`:
206
306
 
207
- ### Option 2: VSCode Dev Container
307
+ ```bash
308
+ brew install superdurable/tap/dexcli
309
+ dexcli dev
310
+ ```
208
311
 
209
- Dev Container is an easy way to get dex-server running locally. Follow these steps to launch a dev container:
210
- - Install Docker, VSCode, and [VSCode Dev Container plugin](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
211
- - Open the project in VSCode.
212
- ```bash
213
- cd dex-python-sdk
214
- code .
215
- ```
216
- - Launch the Remote-Containers: Reopen in Container command from Command Palette (Ctrl + Shift + P). You can also click in the bottom left corner to access the remote container menu.
217
- - Once the dev container starts, dex-server will be listening on port 8801.
312
+ Dex Server listens on `127.0.0.1:8801`. See the
313
+ [CLI README](../cli/README.md) for endpoints and persistence options.
218
314
 
219
315
  ## How To Contribute
220
316
 
@@ -55,7 +55,7 @@ registry = dex.Registry((flow,))
55
55
  ```
56
56
 
57
57
  Registry derives codecs from declared Python types and handler annotations.
58
- Built-in scalar types and dataclasses need no codec arguments. Register an
58
+ Built-in primitive types and dataclasses need no codec arguments. Register an
59
59
  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.
@@ -115,6 +115,71 @@ Applications implement two generic interfaces from [`dex`](dex/):
115
115
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
116
116
  two handler calls. Timer and channel conditions determine how long a Step waits.
117
117
 
118
+ `wait_for_retry` and `execute_retry` limit one logical handler execution. With
119
+ `StepDurability.ASYNC`, local and fallback regular activities share maximum
120
+ attempts, total duration, and 1-based attempt numbers. Fallback starts
121
+ immediately; later regular retries continue the backoff sequence at the
122
+ cumulative attempt.
123
+
124
+ ### Canceling Step executions
125
+
126
+ A successful Step can cancel queued or active executions while continuing with
127
+ its normal decision:
128
+
129
+ ```python
130
+ return (
131
+ dex.go_to(self.record_quote, quote)
132
+ .with_canceling_sibling_steps(self.carrier_a, self.carrier_b)
133
+ .with_canceling_steps(self.global_quote_timeout)
134
+ )
135
+ ```
136
+
137
+ `with_canceling_steps` selects every current execution of each registered Step
138
+ type. `with_canceling_sibling_steps` selects only executions with the same
139
+ `Context.from_step_execution_id` as the current execution. Decisions are
140
+ immutable; repeated calls form a union, and Flow-wide selection wins for the
141
+ same Step type. Unregistered selectors produce an invalid Step result.
142
+
143
+ Dex resolves one snapshot after the current execution succeeds. Completed,
144
+ already-canceled, and absent targets are no-ops. Next Steps created by the same
145
+ decision are outside the snapshot. Dex immediately applies the next or close
146
+ action; late decisions, writes, retries, and recovery Steps are discarded.
147
+
148
+ Set `StepOptions.heartbeat_timeout` on long-running regular Steps so
149
+ cancellation reaches the Worker promptly. It applies to `wait_for` and
150
+ `execute`; local activities ignore it, while an ASYNC fallback uses it. `None`
151
+ and zero disable heartbeats, and positive values must be whole seconds in the
152
+ signed int32 range. `AsyncWorker` cancels the handler's asyncio task. A handler
153
+ may catch `asyncio.CancelledError` for cleanup; synchronous CPU-bound handlers
154
+ may check `Context.is_cancellation_requested()` at natural boundaries.
155
+
156
+ `RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
157
+ RPCs do not support sibling selection because they have no Step execution
158
+ lineage.
159
+
160
+ ### Soft Flow timeout
161
+
162
+ Override `Flow.handle_timeout` to make a positive timeout use handler policy
163
+ by default. Both synchronous and async Workers support the hook:
164
+
165
+ ```python
166
+ class Orders(dex.Flow[str]):
167
+ async def handle_timeout(self, context: dex.Context) -> dex.StepDecision:
168
+ await notify_expiration(context)
169
+ return dex.force_complete("expired")
170
+
171
+ options = dex.StartFlowOptions(
172
+ timeout=timedelta(minutes=30),
173
+ timeout_policy=dex.FlowTimeoutPolicy.HANDLER,
174
+ )
175
+ ```
176
+
177
+ Register async hooks with `allow_async_handlers=True` and run them with
178
+ `AsyncWorker`. `FAIL` produces `FlowErrorType.FLOW_TIMEOUT` and permits Flow
179
+ retry; `CANCEL` cancels without retry. Continue-as-new preserves the deadline,
180
+ while retry and cron runs receive a fresh budget. A zero or absent timeout
181
+ disables the feature.
182
+
118
183
  `Registry` validates every Flow, Step, RPC signature, durable name, lock, and
119
184
  codec before Client or Worker startup. `Client` methods use these typed objects
120
185
  instead of raw Flow, Step, or RPC strings.
@@ -125,18 +190,54 @@ instead of raw Flow, Step, or RPC strings.
125
190
  `Wait.any_combination_of` must have a non-empty user ID; the same Condition
126
191
  instance may appear in multiple combinations.
127
192
 
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
193
+ Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
194
+ overloads of `wait_for_attribute_equal`. They target the current run and accept
195
+ only string, bool, int, or float wire values. JSON objects, bytes, and null fail
131
196
  before transport. `AttributeMap.get_map_size/get_all_instance_keys` include
132
197
  buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
133
198
  include buffered publishes, and omit empty instances. Keys are decoded and
134
199
  sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
135
200
 
201
+ `Client.wait_for_flow` and `AsyncClient.wait_for_flow` return a
202
+ `FlowResult` after hydrating every output-bearing completion. Use
203
+ `single_output` only when the Flow contract produces exactly one output:
204
+
205
+ ```python
206
+ output = client.wait_for_flow(flow_id).single_output(OrderResult)
207
+
208
+ result = client.wait_for_flow(flow_id)
209
+ for completion in result.completions:
210
+ if completion.step_execution_id == expected_execution_id:
211
+ output = completion.decode(OrderResult)
212
+ ```
213
+
214
+ `completions` is an immutable tuple in server collection order. Parallel branch
215
+ order is not deterministic, so select by `step_type` or `step_execution_id`.
216
+ No-output Flows return an empty tuple; `single_output` raises `ValueError` for
217
+ zero or multiple completions. Every terminal status returns a `FlowResult`; inspect
218
+ `status`, `error_type`, and `error_message` for unsuccessful completion.
219
+
220
+ SubFlows are normal, independently addressable Flows used as durable Conditions:
221
+
222
+ ```python
223
+ def wait_for(self, context: Context, input: ChargeInput) -> Wait:
224
+ return Wait.until(SubFlow.run(self.charge_flow, input))
225
+
226
+ def execute(self, context: Context, input: ChargeInput) -> StepDecision:
227
+ del input
228
+ receipt = SubFlow.get_condition_results(context).single_output(Receipt)
229
+ return graceful_complete(receipt)
230
+ ```
231
+
232
+ `SubFlow.get_flow_id(context, index=0)` remains available for a running `any_of`
233
+ loser. `SubFlowOptions` configures timing, timeout policy, retry, initial target
234
+ Attributes, Flow config, Condition ID, and reuse. Parent completion does not cancel an unfinished
235
+ SubFlow.
236
+
136
237
  ### Errors
137
238
 
138
239
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
139
- (`get_attribute`, `describe_flow`, `wait_for_flow`, and `reset_flow`) raise
240
+ (`get_attribute`, `describe_flow`, `wait_for_flow`, and `time_travel`) raise
140
241
  `FlowNotFoundError` when the Flow does not exist. Mutations, RPCs, timer/Step
141
242
  waits, config updates, and continue-as-new triggers raise
142
243
  `FlowNotActiveError` when no running Flow can accept the operation.
@@ -183,22 +284,17 @@ implemented. Python owns its gRPC transport; the native bridge is limited to
183
284
  the shared BlobCache. Design notes:
184
285
  [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
185
286
 
186
- ## Running dex-server locally
287
+ ## Running Dex locally
187
288
 
188
- ### Option 1: use docker compose
189
- See [dex README](https://github.com/superdurable/dex#using-docker-image--docker-compose)
289
+ Install and start the complete local environment with `dexcli`:
190
290
 
191
- ### Option 2: VSCode Dev Container
291
+ ```bash
292
+ brew install superdurable/tap/dexcli
293
+ dexcli dev
294
+ ```
192
295
 
193
- Dev Container is an easy way to get dex-server running locally. Follow these steps to launch a dev container:
194
- - Install Docker, VSCode, and [VSCode Dev Container plugin](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
195
- - Open the project in VSCode.
196
- ```bash
197
- cd dex-python-sdk
198
- code .
199
- ```
200
- - Launch the Remote-Containers: Reopen in Container command from Command Palette (Ctrl + Shift + P). You can also click in the bottom left corner to access the remote container menu.
201
- - Once the dev container starts, dex-server will be listening on port 8801.
296
+ Dex Server listens on `127.0.0.1:8801`. See the
297
+ [CLI README](../cli/README.md) for endpoints and persistence options.
202
298
 
203
299
  ## How To Contribute
204
300
 
@@ -8,6 +8,8 @@
8
8
  # Legacy Materials remain under their original licenses.
9
9
  # See LICENSE and LEGACY_NOTICES.md.
10
10
 
11
+ from dex.async_client import AsyncClient
12
+ from dex.async_worker import AsyncWorker
11
13
  from dex.attribute import (
12
14
  Attribute,
13
15
  AttributeIndex,
@@ -17,8 +19,6 @@ from dex.attribute import (
17
19
  )
18
20
  from dex.blob_cache import BlobCache, BlobCacheConfig, open_blob_cache
19
21
  from dex.channel import Channel, ChannelMap
20
- from dex.async_client import AsyncClient
21
- from dex.async_worker import AsyncWorker
22
22
  from dex.client import Client
23
23
  from dex.client_options import ClientOptions
24
24
  from dex.codec import (
@@ -45,13 +45,18 @@ from dex.flow_info import (
45
45
  SearchFlowsPage,
46
46
  )
47
47
  from dex.flow_options import (
48
+ FlowTimeoutPolicy,
48
49
  IdReusePolicy,
49
- ResetFlowOptions,
50
- ResetType,
50
+ TimeTravelOptions,
51
+ TimeTravelStepMethod,
52
+ TimeTravelType,
51
53
  StartFlowOptions,
54
+ SubFlowOptions,
55
+ SubFlowReusePolicy,
52
56
  StopFlowOptions,
53
57
  StopType,
54
58
  )
59
+ from dex.flow_result import FlowResult, StepCompletion
55
60
  from dex.runtime_errors import (
56
61
  DexServiceError,
57
62
  ErrorSubStatus,
@@ -60,7 +65,6 @@ from dex.runtime_errors import (
60
65
  FlowErrorType,
61
66
  FlowNotActiveError,
62
67
  FlowNotFoundError,
63
- FlowUncompletedError,
64
68
  InvalidStepResultError,
65
69
  LongPollTimeoutError,
66
70
  RpcLockConflictError,
@@ -86,6 +90,7 @@ from dex.step import (
86
90
  )
87
91
  from dex.step_execution import StepExecutionId, TimerId
88
92
  from dex.timer import Timer
93
+ from dex.subflow import SubFlow
89
94
  from dex.wait import Wait
90
95
  from dex.worker import Worker
91
96
  from dex.worker_options import WorkerOptions, WorkerTarget
@@ -121,10 +126,11 @@ __all__ = [
121
126
  "FlowDefinitionError",
122
127
  "FlowErrorType",
123
128
  "FlowInfo",
129
+ "FlowResult",
124
130
  "FlowNotActiveError",
125
131
  "FlowNotFoundError",
126
132
  "FlowStatus",
127
- "FlowUncompletedError",
133
+ "FlowTimeoutPolicy",
128
134
  "HealthInfo",
129
135
  "IdReusePolicy",
130
136
  "InvalidStepResultError",
@@ -135,12 +141,17 @@ __all__ = [
135
141
  "RPCResult",
136
142
  "RpcLockConflictError",
137
143
  "Registry",
138
- "ResetFlowOptions",
139
- "ResetType",
144
+ "TimeTravelOptions",
145
+ "TimeTravelStepMethod",
146
+ "TimeTravelType",
140
147
  "RetryPolicy",
141
148
  "SearchFlowEntry",
142
149
  "SearchFlowsPage",
150
+ "StepCompletion",
143
151
  "StartFlowOptions",
152
+ "SubFlow",
153
+ "SubFlowOptions",
154
+ "SubFlowReusePolicy",
144
155
  "StepExecutionId",
145
156
  "StepDecision",
146
157
  "Step",
@@ -88,13 +88,15 @@ class AsyncValueHydrator:
88
88
  ) -> pb.InvokeExecuteMethodRequest:
89
89
  result = pb.InvokeExecuteMethodRequest()
90
90
  result.CopyFrom(request)
91
- values = [request.step_input]
91
+ has_step_input = request.HasField("step_input")
92
+ values = [request.step_input] if has_step_input else []
92
93
  values.extend(entry.value for entry in request.attributes)
93
94
  values.extend(entry.value for entry in request.step_exe_locals)
94
95
  for channel_result in request.condition_results.channel_results:
95
96
  values.extend(channel_result.values)
96
97
  hydrated = iter(await self.hydrate_all(values))
97
- result.step_input.CopyFrom(next(hydrated))
98
+ if has_step_input:
99
+ result.step_input.CopyFrom(next(hydrated))
98
100
  for entry in result.attributes:
99
101
  entry.value.CopyFrom(next(hydrated))
100
102
  for entry in result.step_exe_locals:
@@ -9,14 +9,14 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  from inspect import isawaitable
12
- from typing import Any
12
+ from typing import Any, Callable
13
13
 
14
14
  from dex._async_value_hydrator import AsyncValueHydrator
15
15
  from dex._invocation_context import InvocationContext, InvocationMethod
16
16
  from dex._value_mapper import ValueMapper
17
- from dex._worker_dispatcher import WorkerDispatcher
17
+ from dex._worker_dispatcher import _TIMEOUT_HANDLER_STEP_TYPE, WorkerDispatcher
18
18
  from dex.dexpb import dex_pb2 as pb
19
- from dex.flow import RPCResult, Registry
19
+ from dex.flow import Registry, RPCResult
20
20
  from dex.runtime_errors import InvalidStepResultError, ValueMappingError
21
21
  from dex.step import StepDecision
22
22
  from dex.wait import Wait
@@ -36,6 +36,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
36
36
  async def invoke_wait_for( # type: ignore[override]
37
37
  self,
38
38
  original: pb.InvokeWaitForMethodRequest,
39
+ is_active: Callable[[], bool] | None = None,
39
40
  ) -> pb.InvokeWaitForMethodResponse:
40
41
  request = await self._async_hydrator.wait_for_request(original)
41
42
  flow = self._registry._flow_by_type(request.flow_type)
@@ -46,6 +47,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
46
47
  request.context,
47
48
  self._values,
48
49
  request.attributes,
50
+ is_active=is_active,
49
51
  )
50
52
  input = self._values.decode(request.step_input, step.input_codec)
51
53
  wait = step.step.wait_for(context, input)
@@ -72,9 +74,12 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
72
74
  async def invoke_execute( # type: ignore[override]
73
75
  self,
74
76
  original: pb.InvokeExecuteMethodRequest,
77
+ is_active: Callable[[], bool] | None = None,
75
78
  ) -> pb.InvokeExecuteMethodResponse:
76
79
  request = await self._async_hydrator.execute_request(original)
77
80
  flow = self._registry._flow_by_type(request.flow_type)
81
+ if request.step_type == _TIMEOUT_HANDLER_STEP_TYPE:
82
+ return await self._invoke_timeout_handler_async(request, flow)
78
83
  step = flow.step(request.step_type)
79
84
  condition_results = (
80
85
  request.condition_results if request.HasField("condition_results") else None
@@ -87,6 +92,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
87
92
  request.attributes,
88
93
  request.step_exe_locals,
89
94
  condition_results,
95
+ is_active=is_active,
90
96
  )
91
97
  input = self._values.decode(request.step_input, step.input_codec)
92
98
  decision: Any = step.step.execute(context, input)
@@ -107,9 +113,56 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
107
113
  flow.name, step.name, "execute", str(error)
108
114
  ) from error
109
115
 
116
+ async def _invoke_timeout_handler_async(
117
+ self,
118
+ request: pb.InvokeExecuteMethodRequest,
119
+ flow: Any,
120
+ ) -> pb.InvokeExecuteMethodResponse:
121
+ if request.HasField("step_input"):
122
+ raise InvalidStepResultError(
123
+ flow.name, _TIMEOUT_HANDLER_STEP_TYPE, "execute", "input must be absent"
124
+ )
125
+ if not flow.has_timeout_handler:
126
+ raise InvalidStepResultError(
127
+ flow.name,
128
+ _TIMEOUT_HANDLER_STEP_TYPE,
129
+ "execute",
130
+ "handler is not registered",
131
+ )
132
+ condition_results = (
133
+ request.condition_results if request.HasField("condition_results") else None
134
+ )
135
+ context = InvocationContext(
136
+ InvocationMethod.EXECUTE,
137
+ flow,
138
+ request.context,
139
+ self._values,
140
+ request.attributes,
141
+ request.step_exe_locals,
142
+ condition_results,
143
+ )
144
+ decision: Any = flow.flow.handle_timeout(context)
145
+ if isawaitable(decision):
146
+ decision = await decision
147
+ try:
148
+ if not isinstance(decision, StepDecision):
149
+ raise TypeError("handle_timeout must return StepDecision")
150
+ return pb.InvokeExecuteMethodResponse(
151
+ step_decision=self._map_decision(flow, decision),
152
+ upsert_attributes=list(context.attribute_writes.values()),
153
+ record_events=context.events,
154
+ upsert_step_exe_locals=list(context.local_writes.values()),
155
+ publish_to_channel=context.publications,
156
+ )
157
+ except (TypeError, ValueError) as error:
158
+ raise InvalidStepResultError(
159
+ flow.name, _TIMEOUT_HANDLER_STEP_TYPE, "execute", str(error)
160
+ ) from error
161
+
110
162
  async def invoke_rpc( # type: ignore[override]
111
163
  self,
112
164
  original: pb.InvokeWorkerRPCRequest,
165
+ is_active: Callable[[], bool] | None = None,
113
166
  ) -> pb.InvokeWorkerRPCResponse:
114
167
  request = await self._async_hydrator.rpc_request(original)
115
168
  flow = self._registry._flow_by_type(request.flow_type)
@@ -121,6 +174,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
121
174
  self._values,
122
175
  request.attributes,
123
176
  channel_infos=dict(request.channel_infos),
177
+ is_active=is_active,
124
178
  )
125
179
  arguments: list[object] = [context]
126
180
  if rpc.input_codec is not None:
@@ -144,6 +198,11 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
144
198
  response.step_decision.next_steps.extend(
145
199
  self._map_movements(flow, returned.next_steps)
146
200
  )
201
+ response.step_decision.cancel_step_types.extend(
202
+ self._map_cancellation_steps(flow, returned.canceling_steps)
203
+ )
204
+ if not returned.next_steps and not returned.canceling_steps:
205
+ response.ClearField("step_decision")
147
206
  elif returned is None and rpc.output_codec is None:
148
207
  response.output.CopyFrom(self._values.encode_dynamic(None))
149
208
  else:
@@ -9,8 +9,9 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  import logging
12
+ from asyncio import CancelledError, current_task
12
13
  from collections.abc import Awaitable, Callable
13
- from typing import TypeVar
14
+ from typing import Any, TypeVar
14
15
 
15
16
  import grpc
16
17
 
@@ -27,39 +28,71 @@ class AsyncWorkerService(dex_pb2_grpc.WorkerServiceServicer):
27
28
  def __init__(self, dispatcher: AsyncWorkerDispatcher) -> None:
28
29
  self._dispatcher = dispatcher
29
30
 
30
- async def InvokeWaitForMethod( # type: ignore[override]
31
+ async def InvokeWaitForMethod(
31
32
  self,
32
33
  request: pb.InvokeWaitForMethodRequest,
33
- context: grpc.aio.ServicerContext,
34
+ context: grpc.aio.ServicerContext[
35
+ pb.InvokeWaitForMethodRequest,
36
+ pb.InvokeWaitForMethodResponse,
37
+ ],
34
38
  ) -> pb.InvokeWaitForMethodResponse:
35
39
  return await self._invoke(
36
- context, lambda: self._dispatcher.invoke_wait_for(request)
40
+ context,
41
+ lambda: self._dispatcher.invoke_wait_for(
42
+ request, lambda: self._is_active(context)
43
+ ),
37
44
  )
38
45
 
39
- async def InvokeExecuteMethod( # type: ignore[override]
46
+ async def InvokeExecuteMethod(
40
47
  self,
41
48
  request: pb.InvokeExecuteMethodRequest,
42
- context: grpc.aio.ServicerContext,
49
+ context: grpc.aio.ServicerContext[
50
+ pb.InvokeExecuteMethodRequest,
51
+ pb.InvokeExecuteMethodResponse,
52
+ ],
43
53
  ) -> pb.InvokeExecuteMethodResponse:
44
54
  return await self._invoke(
45
- context, lambda: self._dispatcher.invoke_execute(request)
55
+ context,
56
+ lambda: self._dispatcher.invoke_execute(
57
+ request, lambda: self._is_active(context)
58
+ ),
46
59
  )
47
60
 
48
- async def InvokeWorkerRPC( # type: ignore[override]
61
+ async def InvokeWorkerRPC(
49
62
  self,
50
63
  request: pb.InvokeWorkerRPCRequest,
51
- context: grpc.aio.ServicerContext,
64
+ context: grpc.aio.ServicerContext[
65
+ pb.InvokeWorkerRPCRequest,
66
+ pb.InvokeWorkerRPCResponse,
67
+ ],
52
68
  ) -> pb.InvokeWorkerRPCResponse:
53
- return await self._invoke(context, lambda: self._dispatcher.invoke_rpc(request))
69
+ return await self._invoke(
70
+ context,
71
+ lambda: self._dispatcher.invoke_rpc(
72
+ request, lambda: self._is_active(context)
73
+ ),
74
+ )
75
+
76
+ @staticmethod
77
+ def _is_active(context: grpc.aio.ServicerContext[Any, Any]) -> bool:
78
+ task = current_task()
79
+ return not context.cancelled() and (task is None or task.cancelling() == 0)
54
80
 
55
81
  @staticmethod
56
82
  async def _invoke(
57
- context: grpc.aio.ServicerContext,
83
+ context: grpc.aio.ServicerContext[Any, Any],
58
84
  invocation: Callable[[], Awaitable[ResponseT]],
59
85
  ) -> ResponseT:
60
86
  try:
61
87
  return await invocation()
88
+ except CancelledError:
89
+ raise
62
90
  except BaseException as error:
91
+ if not AsyncWorkerService._is_active(context):
92
+ await context.abort(
93
+ grpc.StatusCode.CANCELLED,
94
+ "Python AsyncWorker invocation canceled",
95
+ )
63
96
  _LOGGER.exception("Python AsyncWorker invocation failed")
64
97
  await async_abort_worker_error(context, error)
65
98
  raise RuntimeError("gRPC abort returned unexpectedly") from error