dex-python-sdk 0.2.6__tar.gz → 0.2.8__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.6 → dex_python_sdk-0.2.8}/PKG-INFO +21 -2
  2. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/README.md +20 -1
  3. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/__init__.py +8 -1
  4. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_async_worker_dispatcher.py +68 -28
  5. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_invocation_context.py +48 -0
  6. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/context.py +13 -0
  7. dex_python_sdk-0.2.8/dex/stream.py +408 -0
  8. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/pyproject.toml +1 -1
  9. dex_python_sdk-0.2.6/dex/stream.py +0 -106
  10. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/LEGACY_NOTICES.md +0 -0
  11. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/LICENSE +0 -0
  12. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_async_value_hydrator.py +0 -0
  13. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_async_worker_service.py +0 -0
  14. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_grpc_errors.py +0 -0
  15. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_native.pyi +0 -0
  16. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_utils.py +0 -0
  17. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_value_hydrator.py +0 -0
  18. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_value_mapper.py +0 -0
  19. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_worker_dispatcher.py +0 -0
  20. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/_worker_service.py +0 -0
  21. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/async_client.py +0 -0
  22. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/async_worker.py +0 -0
  23. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/attribute.py +0 -0
  24. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/blob_cache.py +0 -0
  25. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/channel.py +0 -0
  26. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/client.py +0 -0
  27. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/client_options.py +0 -0
  28. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/codec.py +0 -0
  29. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/condition.py +0 -0
  30. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/dexpb/__init__.py +0 -0
  31. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/dexpb/dex_pb2.py +0 -0
  32. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/dexpb/dex_pb2.pyi +0 -0
  33. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/dexpb/dex_pb2_grpc.py +0 -0
  34. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/flow.py +0 -0
  35. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/flow_config.py +0 -0
  36. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/flow_info.py +0 -0
  37. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/flow_options.py +0 -0
  38. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/flow_result.py +0 -0
  39. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/py.typed +0 -0
  40. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/runtime_errors.py +0 -0
  41. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/step.py +0 -0
  42. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/step_execution.py +0 -0
  43. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/subflow.py +0 -0
  44. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/timer.py +0 -0
  45. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/wait.py +0 -0
  46. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/worker.py +0 -0
  47. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/dex/worker_options.py +0 -0
  48. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/Cargo.lock +0 -0
  49. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/Cargo.toml +0 -0
  50. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  51. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  60. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  61. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  62. {dex_python_sdk-0.2.6 → dex_python_sdk-0.2.8}/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.6
3
+ Version: 0.2.8
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
@@ -173,11 +173,30 @@ Worker output queue:
173
173
  async def execute(
174
174
  self, context: dex.AsyncContext, input: str
175
175
  ) -> dex.StepDecision:
176
- progress.write(context, "started")
176
+ writer = progress.buffered_text(context)
177
+ writer.write("started")
177
178
  await context.heartbeat({"offset": 10})
178
179
  return dex.graceful_complete(input)
179
180
  ```
180
181
 
182
+ The async buffered writer has a synchronous `write` method, so `writer.write`
183
+ can be passed directly as an LLM delta callback. It flushes after one second, at
184
+ a soft 16 KiB UTF-8 threshold, or before the final result or error. It
185
+ 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
+
181
200
  Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
182
201
  details. Passing Python `None` persists a present null Value. On a later regular
183
202
  attempt, use `context.has_last_heartbeat_value()` before decoding with
@@ -157,11 +157,30 @@ Worker output queue:
157
157
  async def execute(
158
158
  self, context: dex.AsyncContext, input: str
159
159
  ) -> dex.StepDecision:
160
- progress.write(context, "started")
160
+ writer = progress.buffered_text(context)
161
+ writer.write("started")
161
162
  await context.heartbeat({"offset": 10})
162
163
  return dex.graceful_complete(input)
163
164
  ```
164
165
 
166
+ The async buffered writer has a synchronous `write` method, so `writer.write`
167
+ can be passed directly as an LLM delta callback. It flushes after one second, at
168
+ a soft 16 KiB UTF-8 threshold, or before the final result or error. It
169
+ 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
+
165
184
  Call `heartbeat()` or `await context.heartbeat()` without a value to clear previous
166
185
  details. Passing Python `None` persists a present null Value. On a later regular
167
186
  attempt, use `context.has_last_heartbeat_value()` before decoding with
@@ -92,7 +92,12 @@ from dex.step import (
92
92
  heartbeat,
93
93
  )
94
94
  from dex.step_execution import StepExecutionId, TimerId
95
- from dex.stream import Stream, StreamMessage
95
+ from dex.stream import (
96
+ AsyncBufferedTextStream,
97
+ BufferedTextStream,
98
+ Stream,
99
+ StreamMessage,
100
+ )
96
101
  from dex.subflow import SubFlow
97
102
  from dex.timer import Timer
98
103
  from dex.wait import Wait
@@ -112,9 +117,11 @@ __all__ = [
112
117
  "AttributeMap",
113
118
  "AsyncContext",
114
119
  "AsyncClient",
120
+ "AsyncBufferedTextStream",
115
121
  "AsyncWorker",
116
122
  "BlobCache",
117
123
  "BlobCacheConfig",
124
+ "BufferedTextStream",
118
125
  "Channel",
119
126
  "ChannelMap",
120
127
  "Client",
@@ -29,6 +29,7 @@ class _AsyncStepOutputEmitter:
29
29
  def __init__(self) -> None:
30
30
  self.queue: asyncio.Queue[StepOutput] = asyncio.Queue()
31
31
  self._is_closed = False
32
+ self._close_callbacks: list[Callable[[], None]] = []
32
33
 
33
34
  def emit_nowait(self, output: StepOutput) -> None:
34
35
  if self._is_closed:
@@ -44,7 +45,17 @@ class _AsyncStepOutputEmitter:
44
45
  raise asyncio.CancelledError
45
46
 
46
47
  def close(self) -> None:
48
+ if self._is_closed:
49
+ return
47
50
  self._is_closed = True
51
+ for callback in self._close_callbacks:
52
+ callback()
53
+
54
+ def add_close_callback(self, callback: Callable[[], None]) -> None:
55
+ if self._is_closed:
56
+ callback()
57
+ return
58
+ self._close_callbacks.append(callback)
48
59
 
49
60
 
50
61
  class AsyncWorkerDispatcher(WorkerDispatcher):
@@ -93,18 +104,21 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
93
104
  output_emitter=emitter,
94
105
  )
95
106
  input = self._values.decode(request.step_input, step.input_codec)
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
- )
105
- if isawaitable(wait):
106
- wait = await wait
107
+ response: pb.InvokeWaitForMethodResponse | None = None
108
+ failure: BaseException | None = None
109
+ cause: BaseException | None = None
107
110
  try:
111
+ wait = step.step.wait_for(context, input)
112
+ if isgenerator(wait):
113
+ wait.close()
114
+ raise InvalidStepResultError(
115
+ flow.name,
116
+ step.name,
117
+ "wait_for",
118
+ "synchronous generators require Worker",
119
+ )
120
+ if isawaitable(wait):
121
+ wait = await wait
108
122
  if not isinstance(wait, Wait):
109
123
  raise TypeError("wait_for must return Wait")
110
124
  response = pb.InvokeWaitForMethodResponse(
@@ -116,11 +130,22 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
116
130
  waiting = self._map_wait(flow, wait)
117
131
  if waiting is not None:
118
132
  response.waiting_condition.CopyFrom(waiting)
119
- return response
133
+ except InvalidStepResultError as error:
134
+ failure = error
120
135
  except (TypeError, ValueError) as error:
121
- raise InvalidStepResultError(
136
+ failure = InvalidStepResultError(
122
137
  flow.name, step.name, "wait_for", str(error)
123
- ) from error
138
+ )
139
+ cause = error
140
+ except BaseException as error:
141
+ failure = error
142
+ combined = context._finalize_step_outputs(failure)
143
+ if combined is not None:
144
+ if cause is not None:
145
+ raise combined from cause
146
+ raise combined
147
+ assert response is not None
148
+ return response
124
149
 
125
150
  async def invoke_execute( # type: ignore[override]
126
151
  self,
@@ -166,31 +191,46 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
166
191
  output_emitter=emitter,
167
192
  )
168
193
  input = self._values.decode(request.step_input, step.input_codec)
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
- )
178
- if isawaitable(decision):
179
- decision = await decision
194
+ response: pb.InvokeExecuteMethodResponse | None = None
195
+ failure: BaseException | None = None
196
+ cause: BaseException | None = None
180
197
  try:
198
+ decision: Any = step.step.execute(context, input)
199
+ if isgenerator(decision):
200
+ decision.close()
201
+ raise InvalidStepResultError(
202
+ flow.name,
203
+ step.name,
204
+ "execute",
205
+ "synchronous generators require Worker",
206
+ )
207
+ if isawaitable(decision):
208
+ decision = await decision
181
209
  if not isinstance(decision, StepDecision):
182
210
  raise TypeError("execute must return StepDecision")
183
- return pb.InvokeExecuteMethodResponse(
211
+ response = pb.InvokeExecuteMethodResponse(
184
212
  step_decision=self._map_decision(flow, decision),
185
213
  upsert_attributes=list(context.attribute_writes.values()),
186
214
  record_events=context.events,
187
215
  upsert_step_exe_locals=list(context.local_writes.values()),
188
216
  publish_to_channel=context.publications,
189
217
  )
218
+ except InvalidStepResultError as error:
219
+ failure = error
190
220
  except (TypeError, ValueError) as error:
191
- raise InvalidStepResultError(
221
+ failure = InvalidStepResultError(
192
222
  flow.name, step.name, "execute", str(error)
193
- ) from error
223
+ )
224
+ cause = error
225
+ except BaseException as error:
226
+ failure = error
227
+ combined = context._finalize_step_outputs(failure)
228
+ if combined is not None:
229
+ if cause is not None:
230
+ raise combined from cause
231
+ raise combined
232
+ assert response is not None
233
+ return response
194
234
 
195
235
  async def _invoke_timeout_handler_async(
196
236
  self,
@@ -46,6 +46,14 @@ class _StepOutputEmitter(Protocol):
46
46
 
47
47
  async def emit(self, output: StepOutput) -> None: ...
48
48
 
49
+ def add_close_callback(self, callback: Callable[[], None]) -> None: ...
50
+
51
+
52
+ class _StepOutputFinalizer(Protocol):
53
+ def _finalize_step_output(self) -> None: ...
54
+
55
+ def _cancel_step_output(self) -> None: ...
56
+
49
57
 
50
58
  class InvocationContext:
51
59
  def __init__(
@@ -76,6 +84,8 @@ class InvocationContext:
76
84
  self.events: list[pb.KV] = []
77
85
  self.publications: list[pb.ChannelMessage] = []
78
86
  self._event_names: set[str] = set()
87
+ self._step_output_finalizers: list[_StepOutputFinalizer] = []
88
+ self._step_outputs_finalized = False
79
89
 
80
90
  @property
81
91
  def flow_id(self) -> str:
@@ -221,6 +231,44 @@ class InvocationContext:
221
231
  self._output_emitter.emit_nowait(output)
222
232
  return None
223
233
 
234
+ def _prepare_buffered_stream(self, definition: Stream[object]) -> bool:
235
+ if self._method not in (InvocationMethod.WAIT_FOR, InvocationMethod.EXECUTE):
236
+ raise ValueError("Buffered Streams require a Step Context")
237
+ self._require_registered(definition)
238
+ return self._output_emitter is not None
239
+
240
+ def _register_step_output_finalizer(
241
+ self,
242
+ finalizer: _StepOutputFinalizer,
243
+ ) -> None:
244
+ if self._step_outputs_finalized or self._output_emitter is None:
245
+ raise ValueError(
246
+ "Buffered Stream finalizers require an active async Step Context"
247
+ )
248
+ self._step_output_finalizers.append(finalizer)
249
+ self._output_emitter.add_close_callback(finalizer._cancel_step_output)
250
+
251
+ def _finalize_step_outputs(
252
+ self,
253
+ failure: BaseException | None = None,
254
+ ) -> BaseException | None:
255
+ if self._step_outputs_finalized:
256
+ return failure
257
+ self._step_outputs_finalized = True
258
+ failures: list[BaseException] = [] if failure is None else [failure]
259
+ for finalizer in self._step_output_finalizers:
260
+ try:
261
+ finalizer._finalize_step_output()
262
+ except BaseException as error:
263
+ failures.append(error)
264
+ if not failures:
265
+ return None
266
+ if len(failures) == 1:
267
+ return failures[0]
268
+ return BaseExceptionGroup(
269
+ "Step handler and buffered Stream finalization failed", failures
270
+ )
271
+
224
272
  def _get_attribute(
225
273
  self,
226
274
  definition: Attribute[ValueT] | AttributeMap[ValueT],
@@ -18,6 +18,12 @@ if TYPE_CHECKING:
18
18
  from dex.step import StepOutput
19
19
  from dex.stream import Stream
20
20
 
21
+ class _StepOutputFinalizer(Protocol):
22
+ def _finalize_step_output(self) -> None: ...
23
+
24
+ def _cancel_step_output(self) -> None: ...
25
+
26
+
21
27
  ValueT = TypeVar("ValueT")
22
28
 
23
29
 
@@ -227,6 +233,13 @@ class Context(Protocol):
227
233
  value: ValueT,
228
234
  ) -> StepOutput | None: ...
229
235
 
236
+ def _prepare_buffered_stream(self, definition: Stream[object]) -> bool: ...
237
+
238
+ def _register_step_output_finalizer(
239
+ self,
240
+ finalizer: _StepOutputFinalizer,
241
+ ) -> None: ...
242
+
230
243
 
231
244
  class AsyncContext(Context, Protocol):
232
245
  """Expose Context operations available to asynchronous Step handlers.
@@ -0,0 +1,408 @@
1
+ # Copyright (c) 2026 Super Durable, Inc.
2
+ #
3
+ # Licensed under the Super Durable Source License 1.0.
4
+ # You may not use this file except in compliance with the License.
5
+ # See the LICENSE file in the repository root.
6
+ #
7
+ # SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import time
13
+ from dataclasses import dataclass
14
+ from datetime import datetime, timedelta
15
+ from typing import TYPE_CHECKING, Generic, TypeVar, cast, overload
16
+
17
+ from dex._utils import require_name
18
+ from dex.step import StepOutput
19
+
20
+ if TYPE_CHECKING:
21
+ from dex.context import AsyncContext, Context
22
+
23
+ ValueT = TypeVar("ValueT")
24
+ _DEFAULT_BUFFERED_TEXT_FLUSH_INTERVAL = timedelta(seconds=1)
25
+ _DEFAULT_BUFFERED_TEXT_MAX_BYTES = 16 * 1024
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class Stream(Generic[ValueT]):
30
+ """Define a typed best-effort resumable Stream owned by one Flow type.
31
+
32
+ Attributes:
33
+ name: Logical name unique within the Flow persistence schema.
34
+ value_type: Python type encoded for every message.
35
+ stream_capacity_bytes: Positive approximate budget shared by all Flow instances.
36
+ """
37
+
38
+ name: str
39
+ value_type: type[ValueT]
40
+ stream_capacity_bytes: int
41
+
42
+ def __post_init__(self) -> None:
43
+ """Validate the Stream definition after dataclass construction.
44
+
45
+ Raises:
46
+ ValueError: If the name is empty or the byte budget is not positive.
47
+ TypeError: If the byte budget is not an integer.
48
+ """
49
+ require_name(self.name)
50
+ if not isinstance(self.stream_capacity_bytes, int):
51
+ raise TypeError("Stream stream_capacity_bytes must be an integer")
52
+ if self.stream_capacity_bytes <= 0:
53
+ raise ValueError("Stream stream_capacity_bytes must be positive")
54
+
55
+ @overload
56
+ def write( # type: ignore[overload-overlap]
57
+ self,
58
+ context: AsyncContext,
59
+ value: ValueT,
60
+ ) -> None:
61
+ """Enqueue one message from an asynchronous Step handler."""
62
+ ...
63
+
64
+ @overload
65
+ def write(self, context: Context, value: ValueT) -> StepOutput:
66
+ """Create one message output for a synchronous Step generator."""
67
+ ...
68
+
69
+ def write(
70
+ self,
71
+ context: Context,
72
+ value: ValueT,
73
+ ) -> StepOutput | None:
74
+ """Emit one best-effort message from the current Step execution.
75
+
76
+ A synchronous generator must yield the returned StepOutput. An asynchronous
77
+ Step calls this method without ``await``; the message enters the invocation's
78
+ ordered output queue immediately. Neither form waits for Dex Stream Store
79
+ persistence. Calls may write the same Stream any number of times. RPC and
80
+ Flow-timeout Contexts reject Stream writes.
81
+
82
+ Args:
83
+ context: Current synchronous or asynchronous Step Context.
84
+ value: Typed message to append.
85
+
86
+ Returns:
87
+ A StepOutput for a synchronous Context, or ``None`` for AsyncContext.
88
+
89
+ Raises:
90
+ ValueError: If the Stream is unregistered or the Context is not a Step.
91
+ ValueMappingError: If ``value`` cannot be encoded.
92
+ """
93
+ return context._write_stream(self, value)
94
+
95
+ @overload
96
+ def buffered_text( # type: ignore[overload-overlap]
97
+ self: Stream[str],
98
+ context: AsyncContext,
99
+ *,
100
+ flush_interval: timedelta = _DEFAULT_BUFFERED_TEXT_FLUSH_INTERVAL,
101
+ max_buffered_bytes: int = _DEFAULT_BUFFERED_TEXT_MAX_BYTES,
102
+ ) -> AsyncBufferedTextStream: ...
103
+
104
+ @overload
105
+ def buffered_text(
106
+ self: Stream[str],
107
+ context: Context,
108
+ *,
109
+ flush_interval: timedelta = _DEFAULT_BUFFERED_TEXT_FLUSH_INTERVAL,
110
+ max_buffered_bytes: int = _DEFAULT_BUFFERED_TEXT_MAX_BYTES,
111
+ ) -> BufferedTextStream: ...
112
+
113
+ def buffered_text(
114
+ self: Stream[str],
115
+ context: Context,
116
+ *,
117
+ flush_interval: timedelta = _DEFAULT_BUFFERED_TEXT_FLUSH_INTERVAL,
118
+ max_buffered_bytes: int = _DEFAULT_BUFFERED_TEXT_MAX_BYTES,
119
+ ) -> AsyncBufferedTextStream | BufferedTextStream:
120
+ """Create an invocation-managed writer that batches text chunks.
121
+
122
+ The first non-empty chunk starts a one-shot timer. Async Step writers append their current
123
+ batch after ``flush_interval`` even without another chunk. Synchronous generator writers
124
+ check the interval only when ``write`` is called and require an explicit final ``flush``.
125
+ Both forms flush early after crossing the soft UTF-8 byte threshold. Chunks are never split
126
+ or modified.
127
+
128
+ AsyncWorker finalizes the writer before sending the invocation result or error. Its
129
+ synchronous ``write`` method can be passed directly as a text-delta callback. Neither form
130
+ waits for Dex Stream Store acknowledgement.
131
+
132
+ Args:
133
+ context: Current asynchronous or synchronous Step Context.
134
+ flush_interval: Positive maximum async buffering interval. Defaults to one second.
135
+ max_buffered_bytes: Positive soft UTF-8 threshold. Defaults to 16 KiB.
136
+
137
+ Returns:
138
+ An async invocation-managed writer, or a cooperative synchronous writer.
139
+
140
+ Raises:
141
+ TypeError: If this Stream does not carry ``str`` values or an option has the wrong type.
142
+ ValueError: If an option is not positive, the Stream is unregistered, or the Context is
143
+ not a Step invocation.
144
+ """
145
+ if self.value_type is not str:
146
+ raise TypeError("Buffered Streams require Stream[str]")
147
+ _validate_buffered_text_options(flush_interval, max_buffered_bytes)
148
+ is_async = context._prepare_buffered_stream(cast(Stream[object], self))
149
+ if is_async:
150
+ writer = AsyncBufferedTextStream(
151
+ self,
152
+ context,
153
+ flush_interval,
154
+ max_buffered_bytes,
155
+ )
156
+ context._register_step_output_finalizer(writer)
157
+ return writer
158
+ return BufferedTextStream(
159
+ self,
160
+ context,
161
+ flush_interval,
162
+ max_buffered_bytes,
163
+ )
164
+
165
+
166
+ @dataclass(frozen=True)
167
+ class StreamMessage(Generic[ValueT]):
168
+ """Describe one retained Stream message returned by a Client.
169
+
170
+ Attributes:
171
+ value: Decoded application message.
172
+ resume_token: Opaque token for the next read.
173
+ created_time: Server-assigned UTC creation time.
174
+ source: Informational client source or Step-generated ``#stepExecutionID``.
175
+ """
176
+
177
+ value: ValueT
178
+ resume_token: str
179
+ created_time: datetime
180
+ source: str
181
+
182
+
183
+ class AsyncBufferedTextStream:
184
+ """Batch text chunks for an asynchronous Step invocation.
185
+
186
+ Create this writer with :meth:`Stream.buffered_text`. ``write`` is synchronous and returns
187
+ after local buffering or Worker-output enqueueing. AsyncWorker automatically stops its timer
188
+ and flushes remaining text before the invocation result or error.
189
+ """
190
+
191
+ def __init__(
192
+ self,
193
+ stream: Stream[str],
194
+ context: Context,
195
+ flush_interval: timedelta,
196
+ max_buffered_bytes: int,
197
+ ) -> None:
198
+ """Initialize an invocation-managed writer from validated factory inputs.
199
+
200
+ Args:
201
+ stream: Registered string Stream receiving each batch.
202
+ context: Current asynchronous Step Context.
203
+ flush_interval: Positive one-shot timer interval.
204
+ max_buffered_bytes: Positive soft UTF-8 batch threshold.
205
+ """
206
+ self._stream = stream
207
+ self._context = context
208
+ self._flush_interval_seconds = flush_interval.total_seconds()
209
+ self._max_buffered_bytes = max_buffered_bytes
210
+ self._buffer: list[str] = []
211
+ self._buffered_bytes = 0
212
+ self._timer: asyncio.TimerHandle | None = None
213
+ self._timer_generation = 0
214
+ self._is_closed = False
215
+ self._terminal_error: BaseException | None = None
216
+ self._loop = asyncio.get_running_loop()
217
+
218
+ def write(self, chunk: str) -> None:
219
+ """Append one text chunk and flush after reaching the soft size threshold.
220
+
221
+ Empty chunks are ignored. The first non-empty chunk starts the configured one-shot timer.
222
+ This method does not wait for the timer or Stream Store acknowledgement.
223
+
224
+ Args:
225
+ chunk: Text to append without modification.
226
+
227
+ Raises:
228
+ TypeError: If ``chunk`` is not a string.
229
+ BaseException: The previously latched timer or Worker-output failure.
230
+ ValueError: If the invocation already finished.
231
+ """
232
+ self._require_open()
233
+ if not isinstance(chunk, str):
234
+ raise TypeError("Buffered Stream chunks must be strings")
235
+ if not chunk:
236
+ return
237
+ was_empty = not self._buffer
238
+ self._buffer.append(chunk)
239
+ self._buffered_bytes += len(chunk.encode("utf-8"))
240
+ if was_empty:
241
+ self._start_timer()
242
+ if self._buffered_bytes >= self._max_buffered_bytes:
243
+ self._stop_timer()
244
+ self._flush_buffer()
245
+
246
+ def _finalize_step_output(self) -> None:
247
+ if self._is_closed:
248
+ if self._terminal_error is not None:
249
+ raise self._terminal_error
250
+ return
251
+ self._stop_timer()
252
+ try:
253
+ if self._terminal_error is not None:
254
+ raise self._terminal_error
255
+ self._flush_buffer()
256
+ finally:
257
+ self._is_closed = True
258
+
259
+ def _cancel_step_output(self) -> None:
260
+ self._stop_timer()
261
+ self._buffer.clear()
262
+ self._buffered_bytes = 0
263
+ self._is_closed = True
264
+
265
+ def _start_timer(self) -> None:
266
+ self._timer_generation += 1
267
+ generation = self._timer_generation
268
+ self._timer = self._loop.call_later(
269
+ self._flush_interval_seconds,
270
+ self._flush_from_timer,
271
+ generation,
272
+ )
273
+
274
+ def _flush_from_timer(self, generation: int) -> None:
275
+ if (
276
+ self._is_closed
277
+ or self._terminal_error is not None
278
+ or generation != self._timer_generation
279
+ ):
280
+ return
281
+ self._timer = None
282
+ try:
283
+ self._flush_buffer()
284
+ except BaseException as error:
285
+ self._terminal_error = error
286
+
287
+ def _stop_timer(self) -> None:
288
+ self._timer_generation += 1
289
+ if self._timer is not None:
290
+ self._timer.cancel()
291
+ self._timer = None
292
+
293
+ def _flush_buffer(self) -> None:
294
+ if not self._buffer:
295
+ return
296
+ value = "".join(self._buffer)
297
+ self._buffer.clear()
298
+ self._buffered_bytes = 0
299
+ try:
300
+ output = self._stream.write(self._context, value)
301
+ if output is not None:
302
+ raise RuntimeError(
303
+ "Async Buffered Stream produced a synchronous StepOutput"
304
+ )
305
+ except BaseException as error:
306
+ self._terminal_error = error
307
+ raise
308
+
309
+ def _require_open(self) -> None:
310
+ if self._terminal_error is not None:
311
+ raise self._terminal_error
312
+ if self._is_closed:
313
+ raise ValueError("Buffered Stream invocation has finished")
314
+
315
+
316
+ class BufferedTextStream:
317
+ """Batch text chunks for a synchronous Step generator.
318
+
319
+ Create this writer with :meth:`Stream.buffered_text`. Yield every output returned by ``write``
320
+ and ``flush``. The interval is cooperative: elapsed time is checked when another chunk arrives.
321
+ """
322
+
323
+ def __init__(
324
+ self,
325
+ stream: Stream[str],
326
+ context: Context,
327
+ flush_interval: timedelta,
328
+ max_buffered_bytes: int,
329
+ ) -> None:
330
+ """Initialize a cooperative writer from validated factory inputs.
331
+
332
+ Args:
333
+ stream: Registered string Stream receiving each batch.
334
+ context: Current synchronous Step Context.
335
+ flush_interval: Positive cooperative flush interval.
336
+ max_buffered_bytes: Positive soft UTF-8 batch threshold.
337
+ """
338
+ self._stream = stream
339
+ self._context = context
340
+ self._flush_interval_seconds = flush_interval.total_seconds()
341
+ self._max_buffered_bytes = max_buffered_bytes
342
+ self._buffer: list[str] = []
343
+ self._buffered_bytes = 0
344
+ self._started_at: float | None = None
345
+
346
+ def write(self, chunk: str) -> tuple[StepOutput, ...]:
347
+ """Append one chunk and return an output when a threshold is reached.
348
+
349
+ Args:
350
+ chunk: Text to append without modification.
351
+
352
+ Returns:
353
+ An empty tuple while buffering, or one StepOutput that the handler must yield.
354
+
355
+ Raises:
356
+ TypeError: If ``chunk`` is not a string.
357
+ ValueMappingError: If the combined text cannot be encoded.
358
+ """
359
+ if not isinstance(chunk, str):
360
+ raise TypeError("Buffered Stream chunks must be strings")
361
+ if not chunk:
362
+ return ()
363
+ if self._started_at is None:
364
+ self._started_at = time.monotonic()
365
+ self._buffer.append(chunk)
366
+ self._buffered_bytes += len(chunk.encode("utf-8"))
367
+ has_elapsed = (
368
+ time.monotonic() - self._started_at >= self._flush_interval_seconds
369
+ )
370
+ if self._buffered_bytes < self._max_buffered_bytes and not has_elapsed:
371
+ return ()
372
+ return self.flush()
373
+
374
+ def flush(self) -> tuple[StepOutput, ...]:
375
+ """Return the current batch as one StepOutput for the handler to yield.
376
+
377
+ Returns:
378
+ An empty tuple for an empty buffer, or one Stream StepOutput.
379
+
380
+ Raises:
381
+ ValueMappingError: If the combined text cannot be encoded.
382
+ """
383
+ if not self._buffer:
384
+ return ()
385
+ value = "".join(self._buffer)
386
+ self._buffer.clear()
387
+ self._buffered_bytes = 0
388
+ self._started_at = None
389
+ output = self._stream.write(self._context, value)
390
+ if output is None:
391
+ raise RuntimeError(
392
+ "Synchronous Buffered Stream did not produce a StepOutput"
393
+ )
394
+ return (output,)
395
+
396
+
397
+ def _validate_buffered_text_options(
398
+ flush_interval: timedelta,
399
+ max_buffered_bytes: int,
400
+ ) -> None:
401
+ if not isinstance(flush_interval, timedelta):
402
+ raise TypeError("Buffered Stream flush_interval must be timedelta")
403
+ if flush_interval <= timedelta(0):
404
+ raise ValueError("Buffered Stream flush_interval must be positive")
405
+ if not isinstance(max_buffered_bytes, int) or isinstance(max_buffered_bytes, bool):
406
+ raise TypeError("Buffered Stream max_buffered_bytes must be an integer")
407
+ if max_buffered_bytes <= 0:
408
+ raise ValueError("Buffered Stream max_buffered_bytes must be positive")
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "dex-python-sdk"
3
3
  # Publish uses tag sdk-python/v*; CI stamps this for release builds.
4
- version = "0.2.6"
4
+ version = "0.2.8"
5
5
  description = "Python SDK for the Dex workflow engine"
6
6
  authors = [{name = "Super Durable"}]
7
7
  readme = "README.md"
@@ -1,106 +0,0 @@
1
- # Copyright (c) 2026 Super Durable, Inc.
2
- #
3
- # Licensed under the Super Durable Source License 1.0.
4
- # You may not use this file except in compliance with the License.
5
- # See the LICENSE file in the repository root.
6
- #
7
- # SPDX-License-Identifier: LicenseRef-Super-Durable-1.0
8
-
9
- from __future__ import annotations
10
-
11
- from dataclasses import dataclass
12
- from datetime import datetime
13
- from typing import TYPE_CHECKING, Generic, TypeVar, overload
14
-
15
- from dex._utils import require_name
16
- from dex.step import StepOutput
17
-
18
- if TYPE_CHECKING:
19
- from dex.context import AsyncContext, Context
20
-
21
- ValueT = TypeVar("ValueT")
22
-
23
-
24
- @dataclass(frozen=True)
25
- class Stream(Generic[ValueT]):
26
- """Define a typed best-effort resumable Stream owned by one Flow type.
27
-
28
- Attributes:
29
- name: Logical name unique within the Flow persistence schema.
30
- value_type: Python type encoded for every message.
31
- stream_capacity_bytes: Positive approximate budget shared by all Flow instances.
32
- """
33
-
34
- name: str
35
- value_type: type[ValueT]
36
- stream_capacity_bytes: int
37
-
38
- def __post_init__(self) -> None:
39
- """Validate the Stream definition after dataclass construction.
40
-
41
- Raises:
42
- ValueError: If the name is empty or the byte budget is not positive.
43
- TypeError: If the byte budget is not an integer.
44
- """
45
- require_name(self.name)
46
- if not isinstance(self.stream_capacity_bytes, int):
47
- raise TypeError("Stream stream_capacity_bytes must be an integer")
48
- if self.stream_capacity_bytes <= 0:
49
- raise ValueError("Stream stream_capacity_bytes must be positive")
50
-
51
- @overload
52
- def write( # type: ignore[overload-overlap]
53
- self,
54
- context: AsyncContext,
55
- value: ValueT,
56
- ) -> None:
57
- """Enqueue one message from an asynchronous Step handler."""
58
- ...
59
-
60
- @overload
61
- def write(self, context: Context, value: ValueT) -> StepOutput:
62
- """Create one message output for a synchronous Step generator."""
63
- ...
64
-
65
- def write(
66
- self,
67
- context: Context,
68
- value: ValueT,
69
- ) -> StepOutput | None:
70
- """Emit one best-effort message from the current Step execution.
71
-
72
- A synchronous generator must yield the returned StepOutput. An asynchronous
73
- Step calls this method without ``await``; the message enters the invocation's
74
- ordered output queue immediately. Neither form waits for Dex Stream Store
75
- persistence. Calls may write the same Stream any number of times. RPC and
76
- Flow-timeout Contexts reject Stream writes.
77
-
78
- Args:
79
- context: Current synchronous or asynchronous Step Context.
80
- value: Typed message to append.
81
-
82
- Returns:
83
- A StepOutput for a synchronous Context, or ``None`` for AsyncContext.
84
-
85
- Raises:
86
- ValueError: If the Stream is unregistered or the Context is not a Step.
87
- ValueMappingError: If ``value`` cannot be encoded.
88
- """
89
- return context._write_stream(self, value)
90
-
91
-
92
- @dataclass(frozen=True)
93
- class StreamMessage(Generic[ValueT]):
94
- """Describe one retained Stream message returned by a Client.
95
-
96
- Attributes:
97
- value: Decoded application message.
98
- resume_token: Opaque token for the next read.
99
- created_time: Server-assigned UTC creation time.
100
- source: Informational client source or Step-generated ``#stepExecutionID``.
101
- """
102
-
103
- value: ValueT
104
- resume_token: str
105
- created_time: datetime
106
- source: str
File without changes