dex-python-sdk 0.2.5__tar.gz → 0.2.7__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 (63) hide show
  1. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/PKG-INFO +88 -38
  2. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/README.md +87 -37
  3. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/__init__.py +14 -2
  4. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_async_value_hydrator.py +15 -4
  5. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_async_worker_dispatcher.py +174 -25
  6. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_async_worker_service.py +33 -9
  7. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_invocation_context.py +103 -34
  8. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_value_hydrator.py +15 -4
  9. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_worker_dispatcher.py +110 -27
  10. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_worker_service.py +24 -4
  11. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/async_client.py +8 -11
  12. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/async_worker.py +0 -1
  13. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/client.py +8 -11
  14. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/context.py +70 -1
  15. dex_python_sdk-0.2.7/dex/dexpb/dex_pb2.py +459 -0
  16. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/dexpb/dex_pb2.pyi +48 -10
  17. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/dexpb/dex_pb2_grpc.py +12 -12
  18. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow.py +35 -9
  19. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/step.py +84 -14
  20. dex_python_sdk-0.2.7/dex/stream.py +420 -0
  21. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/worker.py +0 -1
  22. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/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/stream.py +0 -80
  25. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/LEGACY_NOTICES.md +0 -0
  26. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/LICENSE +0 -0
  27. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_grpc_errors.py +0 -0
  28. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_native.pyi +0 -0
  29. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_utils.py +0 -0
  30. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/_value_mapper.py +0 -0
  31. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/attribute.py +0 -0
  32. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/blob_cache.py +0 -0
  33. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/channel.py +0 -0
  34. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/client_options.py +0 -0
  35. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/codec.py +0 -0
  36. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/condition.py +0 -0
  37. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/dexpb/__init__.py +0 -0
  38. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow_config.py +0 -0
  39. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow_info.py +0 -0
  40. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow_options.py +0 -0
  41. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/flow_result.py +0 -0
  42. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/py.typed +0 -0
  43. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/runtime_errors.py +0 -0
  44. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/step_execution.py +0 -0
  45. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/subflow.py +0 -0
  46. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/timer.py +0 -0
  47. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/wait.py +0 -0
  48. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/dex/worker_options.py +0 -0
  49. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/Cargo.lock +0 -0
  50. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/Cargo.toml +0 -0
  51. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  52. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  53. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  54. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  55. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  56. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  57. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  58. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  59. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  60. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  61. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  62. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  63. {dex_python_sdk-0.2.5 → dex_python_sdk-0.2.7}/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.7
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,77 @@ 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
+ writer = progress.buffered_text(context)
177
+ writer.write("started")
178
+ await context.heartbeat({"offset": 10})
179
+ return dex.graceful_complete(input)
180
+ ```
181
+
182
+ The async buffered writer has synchronous `write` and `flush` methods, so
183
+ `writer.write` can be passed directly as an LLM delta callback. It flushes after
184
+ one second, at a soft 16 KiB UTF-8 threshold, or before the final result or
185
+ error. It concatenates chunks exactly and ignores empty chunks.
186
+
187
+ A synchronous generator uses the cooperative form because only yielded
188
+ `StepOutput` values can reach gRPC:
189
+
190
+ ```python
191
+ writer = progress.buffered_text(context)
192
+ yield from writer.write(delta)
193
+ yield from writer.flush()
194
+ ```
195
+
196
+ Its interval is checked by the next write, and the handler must explicitly
197
+ flush the tail. Retry does not restore either writer's unsent buffer or
198
+ deduplicate sent batches.
199
+
200
+ Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
201
+ details. Passing Python `None` persists a present null Value. On a later regular
202
+ attempt, use `context.has_last_heartbeat_value()` before decoding with
203
+ `context.get_last_heartbeat_value(ExpectedType)`. A Stream frame is also an implicit
204
+ heartbeat, but it preserves the latest explicit heartbeat state.
205
+
206
+ A Step may write the same Stream any number of times. Dex assigns
207
+ `#<stepExecutionID>` as each Step message's `StreamMessage.source`. Client writes
208
+ provide their own non-empty source; duplicate sources and `#` are allowed and every
209
+ write appends:
210
+
211
+ ```python
212
+ client.write_stream(flow_id, progress, "frontend#preview", "rendering")
213
+ message = client.read_stream(flow_id, progress)
214
+ print(message.source)
215
+ ```
216
+
160
217
  ### Canceling Step executions
161
218
 
162
219
  A successful Step can cancel queued or active executions while continuing with
@@ -181,14 +238,6 @@ already-canceled, and absent targets are no-ops. Next Steps created by the same
181
238
  decision are outside the snapshot. Dex immediately applies the next or close
182
239
  action; late decisions, writes, retries, and recovery Steps are discarded.
183
240
 
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
241
  `RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
193
242
  RPCs do not support sibling selection because they have no Step execution
194
243
  lineage.
@@ -298,14 +347,14 @@ serialization, and invalid handler returns use `FlowDefinitionError`,
298
347
  ### Sync vs asyncio
299
348
 
300
349
  - **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).
350
+ Worker. A progress generator cooperatively hands each yielded frame to gRPC;
351
+ `Stream.write` must therefore be yielded. Blocking Client calls inside
352
+ `Step.execute` are safe while other pool threads remain available.
303
353
  - **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
304
354
  `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).
355
+ Step coroutines annotate `AsyncContext`, call `Stream.write` without `await`,
356
+ and await `context.heartbeat`. Inside async `execute`, inject `AsyncClient` —
357
+ do not call sync `Client` on the Worker event loop. Async generators are rejected.
309
358
 
310
359
  Integration scenarios live under
311
360
  [`tests/integ`](tests/integ/README.md). They exercise the same workflows,
@@ -318,7 +367,8 @@ The strongly typed contracts, registry, synchronous Client/Worker, optional
318
367
  `AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
319
368
  implemented. Python owns its gRPC transport; the native bridge is limited to
320
369
  the shared BlobCache. Design notes:
321
- [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
370
+ [`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
371
+ [`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
322
372
 
323
373
  ## Running Dex locally
324
374
 
@@ -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,77 @@ 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
+ writer = progress.buffered_text(context)
161
+ writer.write("started")
162
+ await context.heartbeat({"offset": 10})
163
+ return dex.graceful_complete(input)
164
+ ```
165
+
166
+ The async buffered writer has synchronous `write` and `flush` methods, so
167
+ `writer.write` can be passed directly as an LLM delta callback. It flushes after
168
+ one second, at a soft 16 KiB UTF-8 threshold, or before the final result or
169
+ error. It concatenates chunks exactly and ignores empty chunks.
170
+
171
+ A synchronous generator uses the cooperative form because only yielded
172
+ `StepOutput` values can reach gRPC:
173
+
174
+ ```python
175
+ writer = progress.buffered_text(context)
176
+ yield from writer.write(delta)
177
+ yield from writer.flush()
178
+ ```
179
+
180
+ Its interval is checked by the next write, and the handler must explicitly
181
+ flush the tail. Retry does not restore either writer's unsent buffer or
182
+ deduplicate sent batches.
183
+
184
+ Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
185
+ details. Passing Python `None` persists a present null Value. On a later regular
186
+ attempt, use `context.has_last_heartbeat_value()` before decoding with
187
+ `context.get_last_heartbeat_value(ExpectedType)`. A Stream frame is also an implicit
188
+ heartbeat, but it preserves the latest explicit heartbeat state.
189
+
190
+ A Step may write the same Stream any number of times. Dex assigns
191
+ `#<stepExecutionID>` as each Step message's `StreamMessage.source`. Client writes
192
+ provide their own non-empty source; duplicate sources and `#` are allowed and every
193
+ write appends:
194
+
195
+ ```python
196
+ client.write_stream(flow_id, progress, "frontend#preview", "rendering")
197
+ message = client.read_stream(flow_id, progress)
198
+ print(message.source)
199
+ ```
200
+
144
201
  ### Canceling Step executions
145
202
 
146
203
  A successful Step can cancel queued or active executions while continuing with
@@ -165,14 +222,6 @@ already-canceled, and absent targets are no-ops. Next Steps created by the same
165
222
  decision are outside the snapshot. Dex immediately applies the next or close
166
223
  action; late decisions, writes, retries, and recovery Steps are discarded.
167
224
 
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
225
  `RPCResult.with_canceling_steps` provides the Flow-wide selector for RPCs.
177
226
  RPCs do not support sibling selection because they have no Step execution
178
227
  lineage.
@@ -282,14 +331,14 @@ serialization, and invalid handler returns use `FlowDefinitionError`,
282
331
  ### Sync vs asyncio
283
332
 
284
333
  - **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).
334
+ Worker. A progress generator cooperatively hands each yielded frame to gRPC;
335
+ `Stream.write` must therefore be yielded. Blocking Client calls inside
336
+ `Step.execute` are safe while other pool threads remain available.
287
337
  - **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
288
338
  `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).
339
+ Step coroutines annotate `AsyncContext`, call `Stream.write` without `await`,
340
+ and await `context.heartbeat`. Inside async `execute`, inject `AsyncClient` —
341
+ do not call sync `Client` on the Worker event loop. Async generators are rejected.
293
342
 
294
343
  Integration scenarios live under
295
344
  [`tests/integ`](tests/integ/README.md). They exercise the same workflows,
@@ -302,7 +351,8 @@ The strongly typed contracts, registry, synchronous Client/Worker, optional
302
351
  `AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
303
352
  implemented. Python owns its gRPC transport; the native bridge is limited to
304
353
  the shared BlobCache. Design notes:
305
- [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
354
+ [`python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md) and
355
+ [`python-sdk-step-streaming.md`](../docs/design/plan/python-sdk-step-streaming.md).
306
356
 
307
357
  ## Running Dex locally
308
358
 
@@ -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,9 +89,15 @@ 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
- from dex.stream import Stream, StreamMessage
95
+ from dex.stream import (
96
+ AsyncBufferedTextStream,
97
+ BufferedTextStream,
98
+ Stream,
99
+ StreamMessage,
100
+ )
94
101
  from dex.subflow import SubFlow
95
102
  from dex.timer import Timer
96
103
  from dex.wait import Wait
@@ -108,10 +115,13 @@ __all__ = [
108
115
  "AttributeIndex",
109
116
  "AttributeLock",
110
117
  "AttributeMap",
118
+ "AsyncContext",
111
119
  "AsyncClient",
120
+ "AsyncBufferedTextStream",
112
121
  "AsyncWorker",
113
122
  "BlobCache",
114
123
  "BlobCacheConfig",
124
+ "BufferedTextStream",
115
125
  "Channel",
116
126
  "ChannelMap",
117
127
  "Client",
@@ -161,6 +171,7 @@ __all__ = [
161
171
  "StepList",
162
172
  "StepDurability",
163
173
  "StepMovement",
174
+ "StepOutput",
164
175
  "StepOptions",
165
176
  "Stream",
166
177
  "StreamMessage",
@@ -182,6 +193,7 @@ __all__ = [
182
193
  "force_complete_if_channels_empty",
183
194
  "force_fail",
184
195
  "graceful_complete",
196
+ "heartbeat",
185
197
  "go_to",
186
198
  "go_to_many",
187
199
  "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: