dex-python-sdk 0.2.5__tar.gz → 0.2.6__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 (62) hide show
  1. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/PKG-INFO +69 -38
  2. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/README.md +68 -37
  3. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/__init__.py +6 -1
  4. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_async_value_hydrator.py +15 -4
  5. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_async_worker_dispatcher.py +122 -13
  6. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_async_worker_service.py +33 -9
  7. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_invocation_context.py +58 -37
  8. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_value_hydrator.py +15 -4
  9. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_worker_dispatcher.py +110 -27
  10. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_worker_service.py +24 -4
  11. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/async_client.py +8 -11
  12. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/async_worker.py +0 -1
  13. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/client.py +8 -11
  14. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/context.py +57 -1
  15. dex_python_sdk-0.2.6/dex/dexpb/dex_pb2.py +459 -0
  16. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/dexpb/dex_pb2.pyi +48 -10
  17. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/dexpb/dex_pb2_grpc.py +12 -12
  18. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow.py +35 -9
  19. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/step.py +84 -14
  20. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/stream.py +36 -10
  21. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/worker.py +0 -1
  22. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/pyproject.toml +1 -1
  23. dex_python_sdk-0.2.5/dex/dexpb/dex_pb2.py +0 -451
  24. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/LEGACY_NOTICES.md +0 -0
  25. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/LICENSE +0 -0
  26. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_grpc_errors.py +0 -0
  27. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_native.pyi +0 -0
  28. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_utils.py +0 -0
  29. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/_value_mapper.py +0 -0
  30. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/attribute.py +0 -0
  31. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/blob_cache.py +0 -0
  32. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/channel.py +0 -0
  33. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/client_options.py +0 -0
  34. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/codec.py +0 -0
  35. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/condition.py +0 -0
  36. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/dexpb/__init__.py +0 -0
  37. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow_config.py +0 -0
  38. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow_info.py +0 -0
  39. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow_options.py +0 -0
  40. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/flow_result.py +0 -0
  41. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/py.typed +0 -0
  42. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/runtime_errors.py +0 -0
  43. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/step_execution.py +0 -0
  44. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/subflow.py +0 -0
  45. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/timer.py +0 -0
  46. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/wait.py +0 -0
  47. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/dex/worker_options.py +0 -0
  48. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/Cargo.lock +0 -0
  49. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/Cargo.toml +0 -0
  50. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  51. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  60. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  61. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  62. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.6}/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.2.5
3
+ Version: 0.2.6
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
@@ -29,6 +29,7 @@ the shared Rust Core is used only for BlobCache.
29
29
 
30
30
  ```python
31
31
  from datetime import timedelta
32
+ from typing import Generator
32
33
 
33
34
  import dex
34
35
 
@@ -46,8 +47,9 @@ class Run(dex.Step[str]):
46
47
 
47
48
  def execute(
48
49
  self, context: dex.Context, input: str
49
- ) -> dex.StepDecision:
50
- progress.write(context, "running")
50
+ ) -> Generator[dex.StepOutput, None, dex.StepDecision]:
51
+ yield progress.write(context, "running")
52
+ yield dex.heartbeat({"phase": "running"})
51
53
  return dex.graceful_complete(input)
52
54
 
53
55
  class CounterFlow(dex.Flow[str]):
@@ -78,23 +80,6 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
78
80
  `PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
79
81
  partitions them by definition type.
80
82
 
81
- Streams provide best-effort resumable progress messages. Their approximate byte
82
- budget is shared by all instances of the owning Flow type. Client keys cannot
83
- contain `#`; Step writes generate `runID#stepExecutionID` and allow one write per
84
- Stream per invocation.
85
-
86
- ```python
87
- client.write_stream(flow_id, progress, "frontend/1", "starting")
88
- message = client.read_stream(
89
- flow_id, progress, resume_token, timeout=timedelta(seconds=30)
90
- )
91
- resume_token = message.resume_token
92
- ```
93
-
94
- Async Step handlers await **Stream.write**, and **AsyncClient** exposes matching
95
- async methods. Reads return the decoded value, resume token, creation time, and
96
- idempotency key.
97
-
98
83
  `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
99
84
  Dex Server before opening their listener. Existing indexes return immediately;
100
85
  failure or the default two-minute deadline aborts startup. An indexed
@@ -144,9 +129,10 @@ Applications implement two generic interfaces from [`dex`](dex/):
144
129
  binds the Flow input to the starting Step input. Use `StepList.empty()` when
145
130
  a Flow has no Steps.
146
131
  - `Step[INPUT]` implements `execute` and optionally `wait_for`. The default
147
- Worker path requires synchronous handlers. With `AsyncWorker` and
148
- `Registry(..., allow_async_handlers=True)`, handlers may be `async def` and
149
- `await` an `AsyncClient`.
132
+ Worker accepts ordinary synchronous handlers and generator handlers. A generator
133
+ yields `StepOutput` progress frames and returns its final `Wait` or `StepDecision`.
134
+ With `AsyncWorker` and `Registry(..., allow_async_handlers=True)`, Step
135
+ coroutines use `AsyncContext`; async RPCs keep `Context`.
150
136
 
151
137
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
152
138
  two handler calls. Timer and channel conditions determine how long a Step waits.
@@ -157,6 +143,58 @@ attempts, total duration, and 1-based attempt numbers. Fallback starts
157
143
  immediately; later regular retries continue the backoff sequence at the
158
144
  cumulative attempt.
159
145
 
146
+ The default Step durability is synchronous. A Flow configuration can select
147
+ asynchronous durability, and a Step method override has highest precedence. The
148
+ default retry total duration is four hours. Regular attempts default to a two-hour
149
+ method timeout and one-minute heartbeat timeout; an explicit heartbeat timeout must
150
+ meet the server minimum, which defaults to ten seconds. Asynchronous durability
151
+ first allows at most three local attempts in seven seconds. The local phase ignores
152
+ method timeouts and heartbeat frames before falling back to a regular activity.
153
+
154
+ ### Step progress and heartbeat recovery
155
+
156
+ A synchronous handler yields every heartbeat and Stream write. The generator return
157
+ value is the only final result:
158
+
159
+ ```python
160
+ def execute(
161
+ self, context: dex.Context, input: str
162
+ ) -> Generator[dex.StepOutput, None, dex.StepDecision]:
163
+ yield dex.heartbeat({"offset": 10})
164
+ yield progress.write(context, "processed 10 items")
165
+ return dex.graceful_complete(input)
166
+ ```
167
+
168
+ An asynchronous handler keeps its normal coroutine return. Stream writes enqueue
169
+ without waiting for Stream Store acknowledgement; heartbeat waits only for the
170
+ Worker output queue:
171
+
172
+ ```python
173
+ async def execute(
174
+ self, context: dex.AsyncContext, input: str
175
+ ) -> dex.StepDecision:
176
+ progress.write(context, "started")
177
+ await context.heartbeat({"offset": 10})
178
+ return dex.graceful_complete(input)
179
+ ```
180
+
181
+ Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
182
+ details. Passing Python `None` persists a present null Value. On a later regular
183
+ attempt, use `context.has_last_heartbeat_value()` before decoding with
184
+ `context.get_last_heartbeat_value(ExpectedType)`. A Stream frame is also an implicit
185
+ heartbeat, but it preserves the latest explicit heartbeat state.
186
+
187
+ A Step may write the same Stream any number of times. Dex assigns
188
+ `#<stepExecutionID>` as each Step message's `StreamMessage.source`. Client writes
189
+ provide their own non-empty source; duplicate sources and `#` are allowed and every
190
+ write appends:
191
+
192
+ ```python
193
+ client.write_stream(flow_id, progress, "frontend#preview", "rendering")
194
+ message = client.read_stream(flow_id, progress)
195
+ print(message.source)
196
+ ```
197
+
160
198
  ### Canceling Step executions
161
199
 
162
200
  A successful Step can cancel queued or active executions while continuing with
@@ -181,14 +219,6 @@ already-canceled, and absent targets are no-ops. Next Steps created by the same
181
219
  decision are outside the snapshot. Dex immediately applies the next or close
182
220
  action; late decisions, writes, retries, and recovery Steps are discarded.
183
221
 
184
- Set `StepOptions.heartbeat_timeout` on long-running regular Steps so
185
- cancellation reaches the Worker promptly. It applies to `wait_for` and
186
- `execute`; local activities ignore it, while an ASYNC fallback uses it. `None`
187
- and zero disable heartbeats, and positive values must be whole seconds in the
188
- signed int32 range. `AsyncWorker` cancels the handler's asyncio task. A handler
189
- may catch `asyncio.CancelledError` for cleanup; synchronous CPU-bound handlers
190
- may check `Context.is_cancellation_requested()` at natural boundaries.
191
-
192
222
  `RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
193
223
  RPCs do not support sibling selection because they have no Step execution
194
224
  lineage.
@@ -298,14 +328,14 @@ serialization, and invalid handler returns use `FlowDefinitionError`,
298
328
  ### Sync vs asyncio
299
329
 
300
330
  - **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
301
- Worker. Blocking `Client` calls inside `Step.execute` are safe (one pool
302
- thread is occupied; other RPCs still run).
331
+ Worker. A progress generator cooperatively hands each yielded frame to gRPC;
332
+ `Stream.write` must therefore be yielded. Blocking Client calls inside
333
+ `Step.execute` are safe while other pool threads remain available.
303
334
  - **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
304
335
  `Registry(..., allow_async_handlers=True)` when Steps/RPCs are coroutines.
305
- Inside async `execute`, inject `AsyncClient` — do not call sync `Client` on
306
- the Worker event loop. Sync `Worker` still rejects coroutine handlers at
307
- registry construction unless `allow_async_handlers=True` (and even then the
308
- sync Worker dispatcher rejects awaitable return values).
336
+ Step coroutines annotate `AsyncContext`, call `Stream.write` without `await`,
337
+ and await `context.heartbeat`. Inside async `execute`, inject `AsyncClient` —
338
+ do not call sync `Client` on the Worker event loop. Async generators are rejected.
309
339
 
310
340
  Integration scenarios live under
311
341
  [`tests/integ`](tests/integ/README.md). They exercise the same workflows,
@@ -318,7 +348,8 @@ The strongly typed contracts, registry, synchronous Client/Worker, optional
318
348
  `AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
319
349
  implemented. Python owns its gRPC transport; the native bridge is limited to
320
350
  the shared BlobCache. Design notes:
321
- [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
351
+ [`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
352
+ [`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
322
353
 
323
354
  ## Running Dex locally
324
355
 
@@ -13,6 +13,7 @@ the shared Rust Core is used only for BlobCache.
13
13
 
14
14
  ```python
15
15
  from datetime import timedelta
16
+ from typing import Generator
16
17
 
17
18
  import dex
18
19
 
@@ -30,8 +31,9 @@ class Run(dex.Step[str]):
30
31
 
31
32
  def execute(
32
33
  self, context: dex.Context, input: str
33
- ) -> dex.StepDecision:
34
- progress.write(context, "running")
34
+ ) -> Generator[dex.StepOutput, None, dex.StepDecision]:
35
+ yield progress.write(context, "running")
36
+ yield dex.heartbeat({"phase": "running"})
35
37
  return dex.graceful_complete(input)
36
38
 
37
39
  class CounterFlow(dex.Flow[str]):
@@ -62,23 +64,6 @@ explicit codec only for a custom encoding or a type Registry cannot derive.
62
64
  `PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
63
65
  partitions them by definition type.
64
66
 
65
- Streams provide best-effort resumable progress messages. Their approximate byte
66
- budget is shared by all instances of the owning Flow type. Client keys cannot
67
- contain `#`; Step writes generate `runID#stepExecutionID` and allow one write per
68
- Stream per invocation.
69
-
70
- ```python
71
- client.write_stream(flow_id, progress, "frontend/1", "starting")
72
- message = client.read_stream(
73
- flow_id, progress, resume_token, timeout=timedelta(seconds=30)
74
- )
75
- resume_token = message.resume_token
76
- ```
77
-
78
- Async Step handlers await **Stream.write**, and **AsyncClient** exposes matching
79
- async methods. Reads return the decoded value, resume token, creation time, and
80
- idempotency key.
81
-
82
67
  `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
83
68
  Dex Server before opening their listener. Existing indexes return immediately;
84
69
  failure or the default two-minute deadline aborts startup. An indexed
@@ -128,9 +113,10 @@ Applications implement two generic interfaces from [`dex`](dex/):
128
113
  binds the Flow input to the starting Step input. Use `StepList.empty()` when
129
114
  a Flow has no Steps.
130
115
  - `Step[INPUT]` implements `execute` and optionally `wait_for`. The default
131
- Worker path requires synchronous handlers. With `AsyncWorker` and
132
- `Registry(..., allow_async_handlers=True)`, handlers may be `async def` and
133
- `await` an `AsyncClient`.
116
+ Worker accepts ordinary synchronous handlers and generator handlers. A generator
117
+ yields `StepOutput` progress frames and returns its final `Wait` or `StepDecision`.
118
+ With `AsyncWorker` and `Registry(..., allow_async_handlers=True)`, Step
119
+ coroutines use `AsyncContext`; async RPCs keep `Context`.
134
120
 
135
121
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
136
122
  two handler calls. Timer and channel conditions determine how long a Step waits.
@@ -141,6 +127,58 @@ attempts, total duration, and 1-based attempt numbers. Fallback starts
141
127
  immediately; later regular retries continue the backoff sequence at the
142
128
  cumulative attempt.
143
129
 
130
+ The default Step durability is synchronous. A Flow configuration can select
131
+ asynchronous durability, and a Step method override has highest precedence. The
132
+ default retry total duration is four hours. Regular attempts default to a two-hour
133
+ method timeout and one-minute heartbeat timeout; an explicit heartbeat timeout must
134
+ meet the server minimum, which defaults to ten seconds. Asynchronous durability
135
+ first allows at most three local attempts in seven seconds. The local phase ignores
136
+ method timeouts and heartbeat frames before falling back to a regular activity.
137
+
138
+ ### Step progress and heartbeat recovery
139
+
140
+ A synchronous handler yields every heartbeat and Stream write. The generator return
141
+ value is the only final result:
142
+
143
+ ```python
144
+ def execute(
145
+ self, context: dex.Context, input: str
146
+ ) -> Generator[dex.StepOutput, None, dex.StepDecision]:
147
+ yield dex.heartbeat({"offset": 10})
148
+ yield progress.write(context, "processed 10 items")
149
+ return dex.graceful_complete(input)
150
+ ```
151
+
152
+ An asynchronous handler keeps its normal coroutine return. Stream writes enqueue
153
+ without waiting for Stream Store acknowledgement; heartbeat waits only for the
154
+ Worker output queue:
155
+
156
+ ```python
157
+ async def execute(
158
+ self, context: dex.AsyncContext, input: str
159
+ ) -> dex.StepDecision:
160
+ progress.write(context, "started")
161
+ await context.heartbeat({"offset": 10})
162
+ return dex.graceful_complete(input)
163
+ ```
164
+
165
+ Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
166
+ details. Passing Python `None` persists a present null Value. On a later regular
167
+ attempt, use `context.has_last_heartbeat_value()` before decoding with
168
+ `context.get_last_heartbeat_value(ExpectedType)`. A Stream frame is also an implicit
169
+ heartbeat, but it preserves the latest explicit heartbeat state.
170
+
171
+ A Step may write the same Stream any number of times. Dex assigns
172
+ `#<stepExecutionID>` as each Step message's `StreamMessage.source`. Client writes
173
+ provide their own non-empty source; duplicate sources and `#` are allowed and every
174
+ write appends:
175
+
176
+ ```python
177
+ client.write_stream(flow_id, progress, "frontend#preview", "rendering")
178
+ message = client.read_stream(flow_id, progress)
179
+ print(message.source)
180
+ ```
181
+
144
182
  ### Canceling Step executions
145
183
 
146
184
  A successful Step can cancel queued or active executions while continuing with
@@ -165,14 +203,6 @@ already-canceled, and absent targets are no-ops. Next Steps created by the same
165
203
  decision are outside the snapshot. Dex immediately applies the next or close
166
204
  action; late decisions, writes, retries, and recovery Steps are discarded.
167
205
 
168
- Set `StepOptions.heartbeat_timeout` on long-running regular Steps so
169
- cancellation reaches the Worker promptly. It applies to `wait_for` and
170
- `execute`; local activities ignore it, while an ASYNC fallback uses it. `None`
171
- and zero disable heartbeats, and positive values must be whole seconds in the
172
- signed int32 range. `AsyncWorker` cancels the handler's asyncio task. A handler
173
- may catch `asyncio.CancelledError` for cleanup; synchronous CPU-bound handlers
174
- may check `Context.is_cancellation_requested()` at natural boundaries.
175
-
176
206
  `RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
177
207
  RPCs do not support sibling selection because they have no Step execution
178
208
  lineage.
@@ -282,14 +312,14 @@ serialization, and invalid handler returns use `FlowDefinitionError`,
282
312
  ### Sync vs asyncio
283
313
 
284
314
  - **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
285
- Worker. Blocking `Client` calls inside `Step.execute` are safe (one pool
286
- thread is occupied; other RPCs still run).
315
+ Worker. A progress generator cooperatively hands each yielded frame to gRPC;
316
+ `Stream.write` must therefore be yielded. Blocking Client calls inside
317
+ `Step.execute` are safe while other pool threads remain available.
287
318
  - **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
288
319
  `Registry(..., allow_async_handlers=True)` when Steps/RPCs are coroutines.
289
- Inside async `execute`, inject `AsyncClient` — do not call sync `Client` on
290
- the Worker event loop. Sync `Worker` still rejects coroutine handlers at
291
- registry construction unless `allow_async_handlers=True` (and even then the
292
- sync Worker dispatcher rejects awaitable return values).
320
+ Step coroutines annotate `AsyncContext`, call `Stream.write` without `await`,
321
+ and await `context.heartbeat`. Inside async `execute`, inject `AsyncClient` —
322
+ do not call sync `Client` on the Worker event loop. Async generators are rejected.
293
323
 
294
324
  Integration scenarios live under
295
325
  [`tests/integ`](tests/integ/README.md). They exercise the same workflows,
@@ -302,7 +332,8 @@ The strongly typed contracts, registry, synchronous Client/Worker, optional
302
332
  `AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
303
333
  implemented. Python owns its gRPC transport; the native bridge is limited to
304
334
  the shared BlobCache. Design notes:
305
- [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
335
+ [`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
336
+ [`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
306
337
 
307
338
  ## Running Dex locally
308
339
 
@@ -35,7 +35,7 @@ from dex.codec import (
35
35
  WireKind,
36
36
  )
37
37
  from dex.condition import ConditionCombination
38
- from dex.context import Context
38
+ from dex.context import AsyncContext, Context
39
39
  from dex.flow import Flow, PersistenceSchema, Registry, RPCResult, rpc
40
40
  from dex.flow_config import ActiveStepSearchMode, FlowConfig
41
41
  from dex.flow_info import (
@@ -80,6 +80,7 @@ from dex.step import (
80
80
  StepList,
81
81
  StepMovement,
82
82
  StepOptions,
83
+ StepOutput,
83
84
  WaitForFailurePolicy,
84
85
  dead_end,
85
86
  force_complete,
@@ -88,6 +89,7 @@ from dex.step import (
88
89
  go_to,
89
90
  go_to_many,
90
91
  graceful_complete,
92
+ heartbeat,
91
93
  )
92
94
  from dex.step_execution import StepExecutionId, TimerId
93
95
  from dex.stream import Stream, StreamMessage
@@ -108,6 +110,7 @@ __all__ = [
108
110
  "AttributeIndex",
109
111
  "AttributeLock",
110
112
  "AttributeMap",
113
+ "AsyncContext",
111
114
  "AsyncClient",
112
115
  "AsyncWorker",
113
116
  "BlobCache",
@@ -161,6 +164,7 @@ __all__ = [
161
164
  "StepList",
162
165
  "StepDurability",
163
166
  "StepMovement",
167
+ "StepOutput",
164
168
  "StepOptions",
165
169
  "Stream",
166
170
  "StreamMessage",
@@ -182,6 +186,7 @@ __all__ = [
182
186
  "force_complete_if_channels_empty",
183
187
  "force_fail",
184
188
  "graceful_complete",
189
+ "heartbeat",
185
190
  "go_to",
186
191
  "go_to_many",
187
192
  "open_blob_cache",
@@ -75,10 +75,16 @@ class AsyncValueHydrator:
75
75
  ) -> pb.InvokeWaitForMethodRequest:
76
76
  result = pb.InvokeWaitForMethodRequest()
77
77
  result.CopyFrom(request)
78
- values = [request.step_input, *(entry.value for entry in request.attributes)]
79
- hydrated = await self.hydrate_all(values)
80
- result.step_input.CopyFrom(hydrated[0])
81
- for entry, value in zip(result.attributes, hydrated[1:]):
78
+ has_heartbeat = request.context.HasField("last_heartbeat_value")
79
+ values = [request.step_input]
80
+ if has_heartbeat:
81
+ values.append(request.context.last_heartbeat_value)
82
+ values.extend(entry.value for entry in request.attributes)
83
+ hydrated = iter(await self.hydrate_all(values))
84
+ result.step_input.CopyFrom(next(hydrated))
85
+ if has_heartbeat:
86
+ result.context.last_heartbeat_value.CopyFrom(next(hydrated))
87
+ for entry, value in zip(result.attributes, hydrated):
82
88
  entry.value.CopyFrom(value)
83
89
  return result
84
90
 
@@ -89,7 +95,10 @@ class AsyncValueHydrator:
89
95
  result = pb.InvokeExecuteMethodRequest()
90
96
  result.CopyFrom(request)
91
97
  has_step_input = request.HasField("step_input")
98
+ has_heartbeat = request.context.HasField("last_heartbeat_value")
92
99
  values = [request.step_input] if has_step_input else []
100
+ if has_heartbeat:
101
+ values.append(request.context.last_heartbeat_value)
93
102
  values.extend(entry.value for entry in request.attributes)
94
103
  values.extend(entry.value for entry in request.step_exe_locals)
95
104
  for channel_result in request.condition_results.channel_results:
@@ -97,6 +106,8 @@ class AsyncValueHydrator:
97
106
  hydrated = iter(await self.hydrate_all(values))
98
107
  if has_step_input:
99
108
  result.step_input.CopyFrom(next(hydrated))
109
+ if has_heartbeat:
110
+ result.context.last_heartbeat_value.CopyFrom(next(hydrated))
100
111
  for entry in result.attributes:
101
112
  entry.value.CopyFrom(next(hydrated))
102
113
  for entry in result.step_exe_locals:
@@ -8,7 +8,10 @@
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
- from inspect import isawaitable
11
+ import asyncio
12
+ from collections.abc import AsyncGenerator, AsyncIterator
13
+ from contextlib import suppress
14
+ from inspect import isawaitable, isgenerator
12
15
  from typing import Any, Callable
13
16
 
14
17
  from dex._async_value_hydrator import AsyncValueHydrator
@@ -18,27 +21,64 @@ from dex._worker_dispatcher import _TIMEOUT_HANDLER_STEP_TYPE, WorkerDispatcher
18
21
  from dex.dexpb import dex_pb2 as pb
19
22
  from dex.flow import Registry, RPCResult
20
23
  from dex.runtime_errors import InvalidStepResultError, ValueMappingError
21
- from dex.step import StepDecision
24
+ from dex.step import StepDecision, StepOutput
22
25
  from dex.wait import Wait
23
26
 
24
27
 
28
+ class _AsyncStepOutputEmitter:
29
+ def __init__(self) -> None:
30
+ self.queue: asyncio.Queue[StepOutput] = asyncio.Queue()
31
+ self._is_closed = False
32
+
33
+ def emit_nowait(self, output: StepOutput) -> None:
34
+ if self._is_closed:
35
+ return
36
+ self.queue.put_nowait(output)
37
+
38
+ async def emit(self, output: StepOutput) -> None:
39
+ if self._is_closed:
40
+ raise asyncio.CancelledError
41
+ self.queue.put_nowait(output)
42
+ await asyncio.sleep(0)
43
+ if self._is_closed:
44
+ raise asyncio.CancelledError
45
+
46
+ def close(self) -> None:
47
+ self._is_closed = True
48
+
49
+
25
50
  class AsyncWorkerDispatcher(WorkerDispatcher):
26
51
  def __init__(
27
52
  self,
28
53
  registry: Registry,
29
54
  values: ValueMapper,
30
55
  hydrator: AsyncValueHydrator,
31
- stream_writer: Callable[[pb.WriteStreamRequest], Any],
32
56
  ) -> None:
33
57
  self._registry = registry
34
58
  self._values = values
35
59
  self._async_hydrator = hydrator
36
- self._stream_writer = stream_writer
37
60
 
38
61
  async def invoke_wait_for( # type: ignore[override]
39
62
  self,
40
63
  original: pb.InvokeWaitForMethodRequest,
41
64
  is_active: Callable[[], bool] | None = None,
65
+ ) -> AsyncGenerator[pb.InvokeWaitForMethodOutput, None]:
66
+ emitter = _AsyncStepOutputEmitter()
67
+ handler = asyncio.create_task(
68
+ self._invoke_wait_for_result(original, emitter, is_active)
69
+ )
70
+ try:
71
+ async for output in self._drain_outputs(emitter, handler):
72
+ yield self._map_wait_for_output(output)
73
+ yield pb.InvokeWaitForMethodOutput(result=await handler)
74
+ finally:
75
+ await self._close_invocation(emitter, handler)
76
+
77
+ async def _invoke_wait_for_result(
78
+ self,
79
+ original: pb.InvokeWaitForMethodRequest,
80
+ emitter: _AsyncStepOutputEmitter,
81
+ is_active: Callable[[], bool] | None,
42
82
  ) -> pb.InvokeWaitForMethodResponse:
43
83
  request = await self._async_hydrator.wait_for_request(original)
44
84
  flow = self._registry._flow_by_type(request.flow_type)
@@ -48,12 +88,20 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
48
88
  flow,
49
89
  request.context,
50
90
  self._values,
51
- self._stream_writer,
52
91
  request.attributes,
53
92
  is_active=is_active,
93
+ output_emitter=emitter,
54
94
  )
55
95
  input = self._values.decode(request.step_input, step.input_codec)
56
96
  wait = step.step.wait_for(context, input)
97
+ if isgenerator(wait):
98
+ wait.close()
99
+ raise InvalidStepResultError(
100
+ flow.name,
101
+ step.name,
102
+ "wait_for",
103
+ "synchronous generators require Worker",
104
+ )
57
105
  if isawaitable(wait):
58
106
  wait = await wait
59
107
  try:
@@ -78,11 +126,30 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
78
126
  self,
79
127
  original: pb.InvokeExecuteMethodRequest,
80
128
  is_active: Callable[[], bool] | None = None,
129
+ ) -> AsyncGenerator[pb.InvokeExecuteMethodOutput, None]:
130
+ if original.step_type == _TIMEOUT_HANDLER_STEP_TYPE:
131
+ response = await self._invoke_timeout_handler_async(original)
132
+ yield pb.InvokeExecuteMethodOutput(result=response)
133
+ return
134
+ emitter = _AsyncStepOutputEmitter()
135
+ handler = asyncio.create_task(
136
+ self._invoke_execute_result(original, emitter, is_active)
137
+ )
138
+ try:
139
+ async for output in self._drain_outputs(emitter, handler):
140
+ yield self._map_execute_output(output)
141
+ yield pb.InvokeExecuteMethodOutput(result=await handler)
142
+ finally:
143
+ await self._close_invocation(emitter, handler)
144
+
145
+ async def _invoke_execute_result(
146
+ self,
147
+ original: pb.InvokeExecuteMethodRequest,
148
+ emitter: _AsyncStepOutputEmitter,
149
+ is_active: Callable[[], bool] | None,
81
150
  ) -> pb.InvokeExecuteMethodResponse:
82
151
  request = await self._async_hydrator.execute_request(original)
83
152
  flow = self._registry._flow_by_type(request.flow_type)
84
- if request.step_type == _TIMEOUT_HANDLER_STEP_TYPE:
85
- return await self._invoke_timeout_handler_async(request, flow)
86
153
  step = flow.step(request.step_type)
87
154
  condition_results = (
88
155
  request.condition_results if request.HasField("condition_results") else None
@@ -92,14 +159,22 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
92
159
  flow,
93
160
  request.context,
94
161
  self._values,
95
- self._stream_writer,
96
162
  request.attributes,
97
163
  request.step_exe_locals,
98
164
  condition_results,
99
165
  is_active=is_active,
166
+ output_emitter=emitter,
100
167
  )
101
168
  input = self._values.decode(request.step_input, step.input_codec)
102
169
  decision: Any = step.step.execute(context, input)
170
+ if isgenerator(decision):
171
+ decision.close()
172
+ raise InvalidStepResultError(
173
+ flow.name,
174
+ step.name,
175
+ "execute",
176
+ "synchronous generators require Worker",
177
+ )
103
178
  if isawaitable(decision):
104
179
  decision = await decision
105
180
  try:
@@ -119,9 +194,10 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
119
194
 
120
195
  async def _invoke_timeout_handler_async(
121
196
  self,
122
- request: pb.InvokeExecuteMethodRequest,
123
- flow: Any,
197
+ original: pb.InvokeExecuteMethodRequest,
124
198
  ) -> pb.InvokeExecuteMethodResponse:
199
+ request = await self._async_hydrator.execute_request(original)
200
+ flow = self._registry._flow_by_type(request.flow_type)
125
201
  if request.HasField("step_input"):
126
202
  raise InvalidStepResultError(
127
203
  flow.name, _TIMEOUT_HANDLER_STEP_TYPE, "execute", "input must be absent"
@@ -137,11 +213,10 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
137
213
  request.condition_results if request.HasField("condition_results") else None
138
214
  )
139
215
  context = InvocationContext(
140
- InvocationMethod.EXECUTE,
216
+ InvocationMethod.TIMEOUT,
141
217
  flow,
142
218
  request.context,
143
219
  self._values,
144
- self._stream_writer,
145
220
  request.attributes,
146
221
  request.step_exe_locals,
147
222
  condition_results,
@@ -177,7 +252,6 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
177
252
  flow,
178
253
  request.context,
179
254
  self._values,
180
- self._stream_writer,
181
255
  request.attributes,
182
256
  channel_infos=dict(request.channel_infos),
183
257
  is_active=is_active,
@@ -218,3 +292,38 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
218
292
  raise
219
293
  except (TypeError, ValueError) as error:
220
294
  raise InvalidStepResultError(flow.name, None, "rpc", str(error)) from error
295
+
296
+ async def _drain_outputs(
297
+ self,
298
+ emitter: _AsyncStepOutputEmitter,
299
+ handler: asyncio.Task[Any],
300
+ ) -> AsyncIterator[StepOutput]:
301
+ while not handler.done() or not emitter.queue.empty():
302
+ if not emitter.queue.empty():
303
+ yield emitter.queue.get_nowait()
304
+ continue
305
+ output = asyncio.create_task(emitter.queue.get())
306
+ try:
307
+ done, _ = await asyncio.wait(
308
+ (handler, output),
309
+ return_when=asyncio.FIRST_COMPLETED,
310
+ )
311
+ if output in done:
312
+ yield output.result()
313
+ finally:
314
+ if not output.done():
315
+ output.cancel()
316
+ with suppress(asyncio.CancelledError):
317
+ await output
318
+
319
+ async def _close_invocation(
320
+ self,
321
+ emitter: _AsyncStepOutputEmitter,
322
+ handler: asyncio.Task[Any],
323
+ ) -> None:
324
+ emitter.close()
325
+ if handler.done():
326
+ return
327
+ handler.cancel()
328
+ with suppress(asyncio.CancelledError):
329
+ await handler