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.
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/PKG-INFO +115 -19
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/README.md +114 -18
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/__init__.py +19 -8
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_async_value_hydrator.py +4 -2
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_async_worker_dispatcher.py +62 -3
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_async_worker_service.py +44 -11
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_grpc_errors.py +3 -3
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_invocation_context.py +31 -1
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_value_hydrator.py +11 -2
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_worker_dispatcher.py +274 -12
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_worker_service.py +17 -3
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/async_client.py +138 -74
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/client.py +138 -73
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/codec.py +15 -13
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/condition.py +21 -1
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/context.py +13 -1
- dex_python_sdk-0.1.5/dex/dexpb/dex_pb2.py +441 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/dexpb/dex_pb2.pyi +319 -73
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/dexpb/dex_pb2_grpc.py +24 -21
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/flow.py +65 -2
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/flow_info.py +2 -2
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/flow_options.py +154 -16
- dex_python_sdk-0.1.5/dex/flow_result.py +215 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/runtime_errors.py +3 -64
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/step.py +78 -6
- dex_python_sdk-0.1.5/dex/subflow.py +85 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/pyproject.toml +1 -1
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/Cargo.toml +3 -1
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/Cargo.toml +1 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +2 -1
- dex_python_sdk-0.1.3/dex/dexpb/dex_pb2.py +0 -395
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/LEGACY_NOTICES.md +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/LICENSE +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_native.pyi +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_utils.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/_value_mapper.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/async_worker.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/attribute.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/blob_cache.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/channel.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/client_options.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/dexpb/__init__.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/flow_config.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/py.typed +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/step_execution.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/timer.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/wait.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/worker.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/dex/worker_options.py +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/Cargo.lock +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
- {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
- {dex_python_sdk-0.1.3 → dex_python_sdk-0.1.5}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
- {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
|
+
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
|
|
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
|
|
145
|
-
`
|
|
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 `
|
|
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
|
|
303
|
+
## Running Dex locally
|
|
203
304
|
|
|
204
|
-
|
|
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
|
-
|
|
307
|
+
```bash
|
|
308
|
+
brew install superdurable/tap/dexcli
|
|
309
|
+
dexcli dev
|
|
310
|
+
```
|
|
208
311
|
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
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
|
|
129
|
-
`
|
|
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 `
|
|
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
|
|
287
|
+
## Running Dex locally
|
|
187
288
|
|
|
188
|
-
|
|
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
|
-
|
|
291
|
+
```bash
|
|
292
|
+
brew install superdurable/tap/dexcli
|
|
293
|
+
dexcli dev
|
|
294
|
+
```
|
|
192
295
|
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
139
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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,
|
|
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(
|
|
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,
|
|
55
|
+
context,
|
|
56
|
+
lambda: self._dispatcher.invoke_execute(
|
|
57
|
+
request, lambda: self._is_active(context)
|
|
58
|
+
),
|
|
46
59
|
)
|
|
47
60
|
|
|
48
|
-
async def InvokeWorkerRPC(
|
|
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(
|
|
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
|