dex-python-sdk 0.2.1__tar.gz → 0.2.3__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.1 → dex_python_sdk-0.2.3}/PKG-INFO +23 -4
  2. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/README.md +22 -3
  3. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/__init__.py +9 -6
  4. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_async_worker_dispatcher.py +6 -0
  5. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_invocation_context.py +50 -1
  6. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_worker_dispatcher.py +6 -0
  7. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/async_client.py +108 -3
  8. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/async_worker.py +1 -0
  9. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/client.py +108 -3
  10. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/context.py +7 -0
  11. dex_python_sdk-0.2.3/dex/dexpb/dex_pb2.py +451 -0
  12. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/dexpb/dex_pb2.pyi +48 -0
  13. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/dexpb/dex_pb2_grpc.py +101 -6
  14. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/flow.py +44 -6
  15. dex_python_sdk-0.2.3/dex/stream.py +80 -0
  16. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/worker.py +1 -0
  17. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/pyproject.toml +1 -1
  18. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/Cargo.lock +6 -6
  19. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/Cargo.toml +3 -3
  20. dex_python_sdk-0.2.1/dex/dexpb/dex_pb2.py +0 -443
  21. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/LEGACY_NOTICES.md +0 -0
  22. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/LICENSE +0 -0
  23. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_async_value_hydrator.py +0 -0
  24. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_async_worker_service.py +0 -0
  25. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_grpc_errors.py +0 -0
  26. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_native.pyi +0 -0
  27. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_utils.py +0 -0
  28. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_value_hydrator.py +0 -0
  29. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_value_mapper.py +0 -0
  30. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/_worker_service.py +0 -0
  31. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/attribute.py +0 -0
  32. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/blob_cache.py +0 -0
  33. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/channel.py +0 -0
  34. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/client_options.py +0 -0
  35. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/codec.py +0 -0
  36. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/condition.py +0 -0
  37. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/dexpb/__init__.py +0 -0
  38. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/flow_config.py +0 -0
  39. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/flow_info.py +0 -0
  40. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/flow_options.py +0 -0
  41. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/flow_result.py +0 -0
  42. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/py.typed +0 -0
  43. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/runtime_errors.py +0 -0
  44. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/step.py +0 -0
  45. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/step_execution.py +0 -0
  46. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/subflow.py +0 -0
  47. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/timer.py +0 -0
  48. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/wait.py +0 -0
  49. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/dex/worker_options.py +0 -0
  50. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  51. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  52. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  53. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  54. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  55. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  56. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  57. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  58. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  59. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  60. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  61. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  62. {dex_python_sdk-0.2.1 → dex_python_sdk-0.2.3}/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.1
3
+ Version: 0.2.3
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
@@ -22,7 +22,7 @@ Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
22
22
  ## New user contracts
23
23
 
24
24
  The rewrite targets Python 3.11+ and exposes strongly typed workflow contracts
25
- from `dex`. This phase includes definitions, attributes, channels, waits,
25
+ from `dex`. This phase includes definitions, attributes, channels, streams, waits,
26
26
  decisions, codecs, registry validation, synchronous client calls, and synchronous
27
27
  worker handlers. Python owns its gRPC Client and Worker transport;
28
28
  the shared Rust Core is used only for BlobCache.
@@ -34,6 +34,7 @@ import dex
34
34
 
35
35
  counter = dex.Attribute("counter", int)
36
36
  counters_by_region = dex.AttributeMap("counters-by-region", int)
37
+ progress = dex.Stream("progress", str, 10 * 1024 * 1024)
37
38
 
38
39
  class Run(dex.Step[str]):
39
40
  def wait_for(
@@ -46,6 +47,7 @@ class Run(dex.Step[str]):
46
47
  def execute(
47
48
  self, context: dex.Context, input: str
48
49
  ) -> dex.StepDecision:
50
+ progress.write(context, "running")
49
51
  return dex.graceful_complete(input)
50
52
 
51
53
  class CounterFlow(dex.Flow[str]):
@@ -58,7 +60,7 @@ class CounterFlow(dex.Flow[str]):
58
60
  return dex.StepList.start_step(self.run)
59
61
 
60
62
  def get_persistence_schema(self) -> dex.PersistenceSchema:
61
- return dex.PersistenceSchema.of(counter, counters_by_region)
63
+ return dex.PersistenceSchema.of(counter, counters_by_region, progress)
62
64
 
63
65
  @dex.rpc(name="Increment")
64
66
  def increment(
@@ -73,9 +75,26 @@ registry = dex.Registry((flow,))
73
75
  Registry derives codecs from declared Python types and handler annotations.
74
76
  Built-in primitive types and dataclasses need no codec arguments. Register an
75
77
  explicit codec only for a custom encoding or a type Registry cannot derive.
76
- `PersistenceSchema.of(...)` accepts attributes and channels together and
78
+ `PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
77
79
  partitions them by definition type.
78
80
 
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
+
79
98
  `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
80
99
  Dex Server before opening their listener. Existing indexes return immediately;
81
100
  failure or the default two-minute deadline aborts startup. An indexed
@@ -6,7 +6,7 @@ Python SDK for [Dex workflow engine](https://github.com/superdurable/dex)
6
6
  ## New user contracts
7
7
 
8
8
  The rewrite targets Python 3.11+ and exposes strongly typed workflow contracts
9
- from `dex`. This phase includes definitions, attributes, channels, waits,
9
+ from `dex`. This phase includes definitions, attributes, channels, streams, waits,
10
10
  decisions, codecs, registry validation, synchronous client calls, and synchronous
11
11
  worker handlers. Python owns its gRPC Client and Worker transport;
12
12
  the shared Rust Core is used only for BlobCache.
@@ -18,6 +18,7 @@ import dex
18
18
 
19
19
  counter = dex.Attribute("counter", int)
20
20
  counters_by_region = dex.AttributeMap("counters-by-region", int)
21
+ progress = dex.Stream("progress", str, 10 * 1024 * 1024)
21
22
 
22
23
  class Run(dex.Step[str]):
23
24
  def wait_for(
@@ -30,6 +31,7 @@ class Run(dex.Step[str]):
30
31
  def execute(
31
32
  self, context: dex.Context, input: str
32
33
  ) -> dex.StepDecision:
34
+ progress.write(context, "running")
33
35
  return dex.graceful_complete(input)
34
36
 
35
37
  class CounterFlow(dex.Flow[str]):
@@ -42,7 +44,7 @@ class CounterFlow(dex.Flow[str]):
42
44
  return dex.StepList.start_step(self.run)
43
45
 
44
46
  def get_persistence_schema(self) -> dex.PersistenceSchema:
45
- return dex.PersistenceSchema.of(counter, counters_by_region)
47
+ return dex.PersistenceSchema.of(counter, counters_by_region, progress)
46
48
 
47
49
  @dex.rpc(name="Increment")
48
50
  def increment(
@@ -57,9 +59,26 @@ registry = dex.Registry((flow,))
57
59
  Registry derives codecs from declared Python types and handler annotations.
58
60
  Built-in primitive types and dataclasses need no codec arguments. Register an
59
61
  explicit codec only for a custom encoding or a type Registry cannot derive.
60
- `PersistenceSchema.of(...)` accepts attributes and channels together and
62
+ `PersistenceSchema.of(...)` accepts attributes, channels, and streams together and
61
63
  partitions them by definition type.
62
64
 
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
+
63
82
  `Worker` and `AsyncWorker` synchronize all registered Indexed Attributes with
64
83
  Dex Server before opening their listener. Existing indexes return immediately;
65
84
  failure or the default two-minute deadline aborts startup. An indexed
@@ -47,14 +47,14 @@ from dex.flow_info import (
47
47
  from dex.flow_options import (
48
48
  FlowTimeoutPolicy,
49
49
  IdReusePolicy,
50
- TimeTravelOptions,
51
- TimeTravelStepMethod,
52
- TimeTravelType,
53
50
  StartFlowOptions,
54
- SubFlowOptions,
55
- SubFlowReusePolicy,
56
51
  StopFlowOptions,
57
52
  StopType,
53
+ SubFlowOptions,
54
+ SubFlowReusePolicy,
55
+ TimeTravelOptions,
56
+ TimeTravelStepMethod,
57
+ TimeTravelType,
58
58
  )
59
59
  from dex.flow_result import FlowResult, StepCompletion
60
60
  from dex.runtime_errors import (
@@ -89,8 +89,9 @@ from dex.step import (
89
89
  graceful_complete,
90
90
  )
91
91
  from dex.step_execution import StepExecutionId, TimerId
92
- from dex.timer import Timer
92
+ from dex.stream import Stream, StreamMessage
93
93
  from dex.subflow import SubFlow
94
+ from dex.timer import Timer
94
95
  from dex.wait import Wait
95
96
  from dex.worker import Worker
96
97
  from dex.worker_options import WorkerOptions, WorkerTarget
@@ -159,6 +160,8 @@ __all__ = [
159
160
  "StepDurability",
160
161
  "StepMovement",
161
162
  "StepOptions",
163
+ "Stream",
164
+ "StreamMessage",
162
165
  "StopFlowOptions",
163
166
  "StopType",
164
167
  "Timer",
@@ -28,10 +28,12 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
28
28
  registry: Registry,
29
29
  values: ValueMapper,
30
30
  hydrator: AsyncValueHydrator,
31
+ stream_writer: Callable[[pb.WriteStreamRequest], Any],
31
32
  ) -> None:
32
33
  self._registry = registry
33
34
  self._values = values
34
35
  self._async_hydrator = hydrator
36
+ self._stream_writer = stream_writer
35
37
 
36
38
  async def invoke_wait_for( # type: ignore[override]
37
39
  self,
@@ -46,6 +48,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
46
48
  flow,
47
49
  request.context,
48
50
  self._values,
51
+ self._stream_writer,
49
52
  request.attributes,
50
53
  is_active=is_active,
51
54
  )
@@ -89,6 +92,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
89
92
  flow,
90
93
  request.context,
91
94
  self._values,
95
+ self._stream_writer,
92
96
  request.attributes,
93
97
  request.step_exe_locals,
94
98
  condition_results,
@@ -137,6 +141,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
137
141
  flow,
138
142
  request.context,
139
143
  self._values,
144
+ self._stream_writer,
140
145
  request.attributes,
141
146
  request.step_exe_locals,
142
147
  condition_results,
@@ -172,6 +177,7 @@ class AsyncWorkerDispatcher(WorkerDispatcher):
172
177
  flow,
173
178
  request.context,
174
179
  self._values,
180
+ self._stream_writer,
175
181
  request.attributes,
176
182
  channel_infos=dict(request.channel_infos),
177
183
  is_active=is_active,
@@ -9,6 +9,7 @@
9
9
  from __future__ import annotations
10
10
 
11
11
  from enum import Enum
12
+ from inspect import isawaitable
12
13
  from typing import Any, Callable, Sequence, TypeVar, cast
13
14
  from urllib.parse import unquote
14
15
 
@@ -20,9 +21,12 @@ from dex.codec import Codec
20
21
  from dex.dexpb import dex_pb2 as pb
21
22
  from dex.flow import Registry, _RegisteredFlow
22
23
  from dex.flow_result import FlowResult, flow_result_from_proto
24
+ from dex.stream import Stream
23
25
 
24
26
  ValueT = TypeVar("ValueT")
25
- _Definition = Attribute[Any] | AttributeMap[Any] | Channel[Any] | ChannelMap[Any]
27
+ _Definition = (
28
+ Attribute[Any] | AttributeMap[Any] | Channel[Any] | ChannelMap[Any] | Stream[Any]
29
+ )
26
30
 
27
31
 
28
32
  class InvocationMethod(Enum):
@@ -38,6 +42,7 @@ class InvocationContext:
38
42
  flow: _RegisteredFlow,
39
43
  metadata: pb.Context,
40
44
  values: ValueMapper,
45
+ stream_writer: Callable[[pb.WriteStreamRequest], Any],
41
46
  attributes: Sequence[pb.KV],
42
47
  locals: Sequence[pb.KV] = (),
43
48
  condition_results: pb.ConditionResults | None = None,
@@ -48,6 +53,7 @@ class InvocationContext:
48
53
  self._flow = flow
49
54
  self._metadata = metadata
50
55
  self._values = values
56
+ self._stream_writer = stream_writer
51
57
  self._attributes = self._map_values("Attribute", attributes)
52
58
  self._locals = self._map_values("step-execution local", locals)
53
59
  self._condition_results = condition_results
@@ -58,6 +64,7 @@ class InvocationContext:
58
64
  self.events: list[pb.KV] = []
59
65
  self.publications: list[pb.ChannelMessage] = []
60
66
  self._event_names: set[str] = set()
67
+ self._stream_writes: set[int] = set()
61
68
 
62
69
  @property
63
70
  def flow_id(self) -> str:
@@ -151,6 +158,48 @@ class InvocationContext:
151
158
  self._event_names.add(name)
152
159
  self.events.append(pb.KV(key=name, value=self._values.encode_dynamic(value)))
153
160
 
161
+ def _write_stream(
162
+ self,
163
+ definition: Stream[ValueT],
164
+ value: ValueT,
165
+ ) -> Any:
166
+ if self._method is InvocationMethod.RPC:
167
+ raise ValueError("Stream writes require a Step Context")
168
+ self._require_registered(definition)
169
+ identity = id(definition)
170
+ if identity in self._stream_writes:
171
+ raise ValueError(
172
+ f"Stream {definition.name} was already written by this Step execution"
173
+ )
174
+ request = pb.WriteStreamRequest(
175
+ flow_id=self.flow_id,
176
+ flow_type=self._flow.name,
177
+ stream_name=definition.name,
178
+ stream_capacity_bytes=definition.stream_capacity_bytes,
179
+ value=self._values.encode(
180
+ value,
181
+ self._values.codec(definition.value_type),
182
+ ),
183
+ idempotency_key=f"{self.run_id}#{self.step_execution_id}",
184
+ )
185
+ result = self._stream_writer(request)
186
+ if isawaitable(result):
187
+ self._stream_writes.add(identity)
188
+ return self._finish_async_stream_write(result, identity)
189
+ self._stream_writes.add(identity)
190
+ return None
191
+
192
+ async def _finish_async_stream_write(
193
+ self,
194
+ result: Any,
195
+ identity: int,
196
+ ) -> None:
197
+ try:
198
+ await result
199
+ except BaseException:
200
+ self._stream_writes.remove(identity)
201
+ raise
202
+
154
203
  def _get_attribute(
155
204
  self,
156
205
  definition: Attribute[ValueT] | AttributeMap[ValueT],
@@ -49,10 +49,12 @@ class WorkerDispatcher:
49
49
  registry: Registry,
50
50
  values: ValueMapper,
51
51
  hydrator: ValueHydrator,
52
+ stream_writer: Callable[[pb.WriteStreamRequest], Any],
52
53
  ) -> None:
53
54
  self._registry = registry
54
55
  self._values = values
55
56
  self._hydrator = hydrator
57
+ self._stream_writer = stream_writer
56
58
 
57
59
  def invoke_wait_for(
58
60
  self,
@@ -67,6 +69,7 @@ class WorkerDispatcher:
67
69
  flow,
68
70
  request.context,
69
71
  self._values,
72
+ self._stream_writer,
70
73
  request.attributes,
71
74
  is_active=is_active,
72
75
  )
@@ -112,6 +115,7 @@ class WorkerDispatcher:
112
115
  flow,
113
116
  request.context,
114
117
  self._values,
118
+ self._stream_writer,
115
119
  request.attributes,
116
120
  request.step_exe_locals,
117
121
  condition_results,
@@ -162,6 +166,7 @@ class WorkerDispatcher:
162
166
  flow,
163
167
  request.context,
164
168
  self._values,
169
+ self._stream_writer,
165
170
  request.attributes,
166
171
  request.step_exe_locals,
167
172
  condition_results,
@@ -199,6 +204,7 @@ class WorkerDispatcher:
199
204
  flow,
200
205
  request.context,
201
206
  self._values,
207
+ self._stream_writer,
202
208
  request.attributes,
203
209
  channel_infos=dict(request.channel_infos),
204
210
  is_active=is_active,
@@ -35,18 +35,19 @@ from dex.flow_info import FlowInfo, FlowStatus, SearchFlowEntry, SearchFlowsPage
35
35
  from dex.flow_options import (
36
36
  FlowTimeoutPolicy,
37
37
  IdReusePolicy,
38
- TimeTravelOptions,
39
- TimeTravelStepMethod,
40
- TimeTravelType,
41
38
  StartFlowOptions,
42
39
  StopFlowOptions,
43
40
  StopType,
41
+ TimeTravelOptions,
42
+ TimeTravelStepMethod,
43
+ TimeTravelType,
44
44
  _resolve_flow_timeout_policy,
45
45
  )
46
46
  from dex.flow_result import FlowResult, flow_result_from_proto
47
47
  from dex.runtime_errors import FlowErrorType
48
48
  from dex.step import RetryPolicy, StepDurability
49
49
  from dex.step_execution import StepExecutionId, TimerId
50
+ from dex.stream import Stream, StreamMessage
50
51
 
51
52
  InputT = TypeVar("InputT")
52
53
  OutputT = TypeVar("OutputT")
@@ -97,6 +98,7 @@ class AsyncClient:
97
98
  registry,
98
99
  self._values,
99
100
  cast(Any, self._hydrator),
101
+ self._service.WriteStream,
100
102
  )
101
103
  self._closed = False
102
104
 
@@ -502,6 +504,109 @@ class AsyncClient:
502
504
  "active",
503
505
  )
504
506
 
507
+ async def write_stream(
508
+ self,
509
+ flow_id: str,
510
+ stream: Stream[ValueT],
511
+ idempotency_key: str,
512
+ value: ValueT,
513
+ ) -> None:
514
+ """Await one typed best-effort Stream append with client idempotency.
515
+
516
+ Args:
517
+ flow_id: Logical Flow instance ID; the Flow need not exist or be active.
518
+ stream: Exact Stream object registered in one Flow schema.
519
+ idempotency_key: Non-empty key without ``#``; retained duplicates are no-ops.
520
+ value: Typed message to append.
521
+
522
+ Raises:
523
+ ValueError: If an ID is empty or the key contains the reserved separator.
524
+ FlowDefinitionError: If the Stream is not registered.
525
+ ValueMappingError: If the message cannot be encoded.
526
+ DexServiceError: If FlowService cannot append the message.
527
+ """
528
+ require_name(idempotency_key)
529
+ if "#" in idempotency_key:
530
+ raise ValueError("Stream client idempotency key must not contain #")
531
+ flow = self.registry._flow_for_stream(stream)
532
+ await self._call(
533
+ self._service.WriteStream,
534
+ pb.WriteStreamRequest(
535
+ flow_id=require_name(flow_id),
536
+ flow_type=flow.name,
537
+ stream_name=stream.name,
538
+ stream_capacity_bytes=stream.stream_capacity_bytes,
539
+ value=self._values.encode(
540
+ value,
541
+ self._values.codec(stream.value_type),
542
+ ),
543
+ idempotency_key=idempotency_key,
544
+ ),
545
+ "write_stream",
546
+ flow_id,
547
+ "none",
548
+ )
549
+
550
+ async def read_stream(
551
+ self,
552
+ flow_id: str,
553
+ stream: Stream[ValueT],
554
+ resume_token: str = "",
555
+ timeout: timedelta | None = None,
556
+ ) -> StreamMessage[ValueT]:
557
+ """Await the next retained Stream message after a resume token.
558
+
559
+ Args:
560
+ flow_id: Logical Flow instance ID used as the Stream instance key.
561
+ stream: Exact Stream object registered in one Flow schema.
562
+ resume_token: Previous message token, or empty for the retained head.
563
+ timeout: Optional non-negative server-side long-poll duration.
564
+
565
+ Returns:
566
+ The decoded message, next resume token, creation time, and idempotency key.
567
+
568
+ Raises:
569
+ LongPollTimeoutError: If no message arrives before the server wait expires.
570
+ FlowDefinitionError: If the Stream is not registered.
571
+ ValueMappingError: If the retained message cannot be decoded.
572
+ DexServiceError: If FlowService cannot perform the read.
573
+ """
574
+ flow = self.registry._flow_for_stream(stream)
575
+ response = cast(
576
+ pb.ReadStreamResponse,
577
+ await self._call(
578
+ self._service.ReadStream,
579
+ pb.ReadStreamRequest(
580
+ flow_id=require_name(flow_id),
581
+ flow_type=flow.name,
582
+ stream_name=stream.name,
583
+ resume_token=resume_token,
584
+ wait_time_seconds=(
585
+ 0 if timeout is None else self._seconds32(timeout)
586
+ ),
587
+ ),
588
+ "read_stream",
589
+ flow_id,
590
+ "none",
591
+ ),
592
+ )
593
+ if (
594
+ not response.HasField("message")
595
+ or not response.message.HasField("value")
596
+ or not response.message.HasField("created_time")
597
+ or not response.message.resume_token
598
+ ):
599
+ raise ValueError("Dex returned an incomplete Stream message")
600
+ return StreamMessage(
601
+ self._values.decode(
602
+ response.message.value,
603
+ self._values.codec(stream.value_type),
604
+ ),
605
+ response.message.resume_token,
606
+ response.message.created_time.ToDatetime(tzinfo=timezone.utc),
607
+ response.message.idempotency_key,
608
+ )
609
+
505
610
  async def wait_for_flow(
506
611
  self,
507
612
  flow_id: str,
@@ -69,6 +69,7 @@ class AsyncWorker:
69
69
  registry,
70
70
  values,
71
71
  AsyncValueHydrator(self._flow_service, blob_cache),
72
+ self._flow_service.WriteStream,
72
73
  )
73
74
  self._server = grpc.aio.server()
74
75
  dex_pb2_grpc.add_WorkerServiceServicer_to_server( # type: ignore[no-untyped-call]
@@ -35,18 +35,19 @@ from dex.flow_info import FlowInfo, FlowStatus, SearchFlowEntry, SearchFlowsPage
35
35
  from dex.flow_options import (
36
36
  FlowTimeoutPolicy,
37
37
  IdReusePolicy,
38
- TimeTravelOptions,
39
- TimeTravelStepMethod,
40
- TimeTravelType,
41
38
  StartFlowOptions,
42
39
  StopFlowOptions,
43
40
  StopType,
41
+ TimeTravelOptions,
42
+ TimeTravelStepMethod,
43
+ TimeTravelType,
44
44
  _resolve_flow_timeout_policy,
45
45
  )
46
46
  from dex.flow_result import FlowResult, flow_result_from_proto
47
47
  from dex.runtime_errors import FlowErrorType
48
48
  from dex.step import RetryPolicy, StepDurability
49
49
  from dex.step_execution import StepExecutionId, TimerId
50
+ from dex.stream import Stream, StreamMessage
50
51
 
51
52
  InputT = TypeVar("InputT")
52
53
  OutputT = TypeVar("OutputT")
@@ -97,6 +98,7 @@ class Client:
97
98
  registry,
98
99
  self._values,
99
100
  self._hydrator,
101
+ self._service.WriteStream,
100
102
  )
101
103
  self._closed = False
102
104
 
@@ -503,6 +505,109 @@ class Client:
503
505
  "active",
504
506
  )
505
507
 
508
+ def write_stream(
509
+ self,
510
+ flow_id: str,
511
+ stream: Stream[ValueT],
512
+ idempotency_key: str,
513
+ value: ValueT,
514
+ ) -> None:
515
+ """Append one typed best-effort Stream message with client idempotency.
516
+
517
+ Args:
518
+ flow_id: Logical Flow instance ID; the Flow need not exist or be active.
519
+ stream: Exact Stream object registered in one Flow schema.
520
+ idempotency_key: Non-empty key without ``#``; retained duplicates are no-ops.
521
+ value: Typed message to append.
522
+
523
+ Raises:
524
+ ValueError: If an ID is empty or the key contains the reserved separator.
525
+ FlowDefinitionError: If the Stream is not registered.
526
+ ValueMappingError: If the message cannot be encoded.
527
+ DexServiceError: If FlowService cannot append the message.
528
+ """
529
+ require_name(idempotency_key)
530
+ if "#" in idempotency_key:
531
+ raise ValueError("Stream client idempotency key must not contain #")
532
+ flow = self.registry._flow_for_stream(stream)
533
+ self._call(
534
+ self._service.WriteStream,
535
+ pb.WriteStreamRequest(
536
+ flow_id=require_name(flow_id),
537
+ flow_type=flow.name,
538
+ stream_name=stream.name,
539
+ stream_capacity_bytes=stream.stream_capacity_bytes,
540
+ value=self._values.encode(
541
+ value,
542
+ self._values.codec(stream.value_type),
543
+ ),
544
+ idempotency_key=idempotency_key,
545
+ ),
546
+ "write_stream",
547
+ flow_id,
548
+ "none",
549
+ )
550
+
551
+ def read_stream(
552
+ self,
553
+ flow_id: str,
554
+ stream: Stream[ValueT],
555
+ resume_token: str = "",
556
+ timeout: timedelta | None = None,
557
+ ) -> StreamMessage[ValueT]:
558
+ """Block for the next retained Stream message after a resume token.
559
+
560
+ Args:
561
+ flow_id: Logical Flow instance ID used as the Stream instance key.
562
+ stream: Exact Stream object registered in one Flow schema.
563
+ resume_token: Previous message token, or empty for the retained head.
564
+ timeout: Optional non-negative server-side long-poll duration.
565
+
566
+ Returns:
567
+ The decoded message, next resume token, creation time, and idempotency key.
568
+
569
+ Raises:
570
+ LongPollTimeoutError: If no message arrives before the server wait expires.
571
+ FlowDefinitionError: If the Stream is not registered.
572
+ ValueMappingError: If the retained message cannot be decoded.
573
+ DexServiceError: If FlowService cannot perform the read.
574
+ """
575
+ flow = self.registry._flow_for_stream(stream)
576
+ response = cast(
577
+ pb.ReadStreamResponse,
578
+ self._call(
579
+ self._service.ReadStream,
580
+ pb.ReadStreamRequest(
581
+ flow_id=require_name(flow_id),
582
+ flow_type=flow.name,
583
+ stream_name=stream.name,
584
+ resume_token=resume_token,
585
+ wait_time_seconds=(
586
+ 0 if timeout is None else self._seconds32(timeout)
587
+ ),
588
+ ),
589
+ "read_stream",
590
+ flow_id,
591
+ "none",
592
+ ),
593
+ )
594
+ if (
595
+ not response.HasField("message")
596
+ or not response.message.HasField("value")
597
+ or not response.message.HasField("created_time")
598
+ or not response.message.resume_token
599
+ ):
600
+ raise ValueError("Dex returned an incomplete Stream message")
601
+ return StreamMessage(
602
+ self._values.decode(
603
+ response.message.value,
604
+ self._values.codec(stream.value_type),
605
+ ),
606
+ response.message.resume_token,
607
+ response.message.created_time.ToDatetime(tzinfo=timezone.utc),
608
+ response.message.idempotency_key,
609
+ )
610
+
506
611
  def wait_for_flow(
507
612
  self,
508
613
  flow_id: str,
@@ -15,6 +15,7 @@ from typing import TYPE_CHECKING, Protocol, Sequence, TypeVar
15
15
  if TYPE_CHECKING:
16
16
  from dex.attribute import Attribute, AttributeMap
17
17
  from dex.channel import Channel, ChannelMap
18
+ from dex.stream import Stream
18
19
 
19
20
  ValueT = TypeVar("ValueT")
20
21
 
@@ -189,3 +190,9 @@ class Context(Protocol):
189
190
  self,
190
191
  definition: ChannelMap[object],
191
192
  ) -> tuple[str, ...]: ...
193
+
194
+ def _write_stream(
195
+ self,
196
+ definition: Stream[ValueT],
197
+ value: ValueT,
198
+ ) -> object: ...