dex-python-sdk 0.2.0__tar.gz → 0.2.2__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 (61) hide show
  1. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/PKG-INFO +32 -12
  2. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/README.md +31 -11
  3. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/__init__.py +9 -6
  4. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_async_worker_dispatcher.py +6 -0
  5. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_invocation_context.py +50 -1
  6. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_worker_dispatcher.py +13 -9
  7. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/async_client.py +112 -5
  8. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/async_worker.py +1 -0
  9. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/client.py +112 -5
  10. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/context.py +7 -0
  11. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/dexpb/dex_pb2.py +360 -350
  12. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/dexpb/dex_pb2.pyi +58 -4
  13. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/dexpb/dex_pb2_grpc.py +101 -6
  14. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/flow.py +63 -9
  15. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/flow_config.py +3 -3
  16. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/step.py +15 -15
  17. dex_python_sdk-0.2.2/dex/stream.py +80 -0
  18. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/worker.py +1 -0
  19. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/pyproject.toml +1 -1
  20. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/LEGACY_NOTICES.md +0 -0
  21. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/LICENSE +0 -0
  22. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_async_value_hydrator.py +0 -0
  23. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_async_worker_service.py +0 -0
  24. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_grpc_errors.py +0 -0
  25. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_native.pyi +0 -0
  26. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_utils.py +0 -0
  27. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_value_hydrator.py +0 -0
  28. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_value_mapper.py +0 -0
  29. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/_worker_service.py +0 -0
  30. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/attribute.py +0 -0
  31. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/blob_cache.py +0 -0
  32. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/channel.py +0 -0
  33. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/client_options.py +0 -0
  34. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/codec.py +0 -0
  35. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/condition.py +0 -0
  36. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/dexpb/__init__.py +0 -0
  37. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/flow_info.py +0 -0
  38. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/flow_options.py +0 -0
  39. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/flow_result.py +0 -0
  40. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/py.typed +0 -0
  41. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/runtime_errors.py +0 -0
  42. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/step_execution.py +0 -0
  43. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/subflow.py +0 -0
  44. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/timer.py +0 -0
  45. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/wait.py +0 -0
  46. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/dex/worker_options.py +0 -0
  47. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/Cargo.lock +0 -0
  48. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/Cargo.toml +0 -0
  49. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  50. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  51. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  52. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  53. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  54. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  55. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  56. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  57. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  58. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  59. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  60. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  61. {dex_python_sdk-0.2.0 → dex_python_sdk-0.2.2}/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.0
3
+ Version: 0.2.2
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
@@ -96,13 +115,14 @@ Flow configuration:
96
115
 
97
116
  ```python
98
117
  email = dex.Attribute("customer-email", str, sync_to_attribute_store=True)
99
- config = dex.FlowConfig(attribute_store_name="profiles")
118
+ config = dex.FlowConfig(attribute_store_names=["profiles", "audit"])
100
119
  ```
101
120
 
102
- The Store is an asynchronous latest-state projection. Deletion writes SQL
103
- `NULL`, and projection failures do not roll back Flow Attributes. `None`
104
- preserves the current target; an explicit empty string disables future
105
- synchronization while retaining protocol presence.
121
+ Stores are asynchronous latest-state projections. Every enabled Attribute write
122
+ is sent to every selected Store. Deletion writes SQL `NULL`, and projection
123
+ failures do not roll back Flow Attributes. `None` preserves current targets;
124
+ an explicit empty list disables future synchronization while retaining protocol
125
+ presence.
106
126
 
107
127
  ```
108
128
  pip install dex-python-sdk==0.1.0
@@ -144,9 +164,9 @@ its normal decision:
144
164
 
145
165
  ```python
146
166
  return (
147
- dex.go_to(self.record_quote, quote)
148
- .with_canceling_sibling_steps(self.carrier_a, self.carrier_b)
149
- .with_canceling_steps(self.global_quote_timeout)
167
+ dex.go_to(RecordQuote, quote)
168
+ .with_canceling_sibling_steps(QuoteCarrierA, QuoteCarrierB)
169
+ .with_canceling_steps(GlobalQuoteTimeout)
150
170
  )
151
171
  ```
152
172
 
@@ -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
@@ -80,13 +99,14 @@ Flow configuration:
80
99
 
81
100
  ```python
82
101
  email = dex.Attribute("customer-email", str, sync_to_attribute_store=True)
83
- config = dex.FlowConfig(attribute_store_name="profiles")
102
+ config = dex.FlowConfig(attribute_store_names=["profiles", "audit"])
84
103
  ```
85
104
 
86
- The Store is an asynchronous latest-state projection. Deletion writes SQL
87
- `NULL`, and projection failures do not roll back Flow Attributes. `None`
88
- preserves the current target; an explicit empty string disables future
89
- synchronization while retaining protocol presence.
105
+ Stores are asynchronous latest-state projections. Every enabled Attribute write
106
+ is sent to every selected Store. Deletion writes SQL `NULL`, and projection
107
+ failures do not roll back Flow Attributes. `None` preserves current targets;
108
+ an explicit empty list disables future synchronization while retaining protocol
109
+ presence.
90
110
 
91
111
  ```
92
112
  pip install dex-python-sdk==0.1.0
@@ -128,9 +148,9 @@ its normal decision:
128
148
 
129
149
  ```python
130
150
  return (
131
- dex.go_to(self.record_quote, quote)
132
- .with_canceling_sibling_steps(self.carrier_a, self.carrier_b)
133
- .with_canceling_steps(self.global_quote_timeout)
151
+ dex.go_to(RecordQuote, quote)
152
+ .with_canceling_sibling_steps(QuoteCarrierA, QuoteCarrierB)
153
+ .with_canceling_steps(GlobalQuoteTimeout)
134
154
  )
135
155
  ```
136
156
 
@@ -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
+ max_estimated_bytes=definition.max_estimated_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,
@@ -456,8 +462,10 @@ class WorkerDispatcher:
456
462
  is_headless_address=config.worker_target.headless,
457
463
  )
458
464
  )
459
- if config.attribute_store_name is not None:
460
- mapped.attribute_sync_config_name = config.attribute_store_name
465
+ if config.attribute_store_names is not None:
466
+ mapped.attribute_store_names.CopyFrom(
467
+ pb.AttributeStoreNames(names=config.attribute_store_names)
468
+ )
461
469
  return mapped
462
470
 
463
471
  def _map_decision(
@@ -529,7 +537,7 @@ class WorkerDispatcher:
529
537
  def _map_cancellation_steps(
530
538
  self,
531
539
  flow: _RegisteredFlow,
532
- steps: tuple[Step[Any], ...],
540
+ steps: tuple[type[Step[Any]], ...],
533
541
  ) -> list[str]:
534
542
  step_types: list[str] = []
535
543
  seen: set[str] = set()
@@ -585,13 +593,9 @@ class WorkerDispatcher:
585
593
  @staticmethod
586
594
  def _registered_movement_target(
587
595
  flow: _RegisteredFlow,
588
- step: object,
596
+ step: type[Step[Any]],
589
597
  ) -> _RegisteredStep:
590
- step_type = getattr(step, "get_step_type", lambda: "")()
591
- target = flow.step(step_type)
592
- if target.step is not step:
593
- raise ValueError("Step movement target does not belong to Flow")
594
- return target
598
+ return flow.step_class(step)
595
599
 
596
600
  @staticmethod
597
601
  def _require_persistence_identity(
@@ -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
+ max_estimated_bytes=stream.max_estimated_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,
@@ -1077,8 +1182,10 @@ class AsyncClient:
1077
1182
  StepDurability.SYNC: pb.STEP_DURABILITY_SYNC,
1078
1183
  StepDurability.ASYNC: pb.STEP_DURABILITY_ASYNC,
1079
1184
  }[config.step_durability]
1080
- if config.attribute_store_name is not None:
1081
- mapped.attribute_sync_config_name = config.attribute_store_name
1185
+ if config.attribute_store_names is not None:
1186
+ mapped.attribute_store_names.CopyFrom(
1187
+ pb.AttributeStoreNames(names=config.attribute_store_names)
1188
+ )
1082
1189
  target = (
1083
1190
  config.worker_target
1084
1191
  if config is not None and config.worker_target is not None
@@ -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]