dex-python-sdk 0.1.2__tar.gz → 0.1.4__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 (60) hide show
  1. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/PKG-INFO +49 -15
  2. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/README.md +48 -14
  3. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/__init__.py +7 -4
  4. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_grpc_errors.py +3 -3
  5. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_invocation_context.py +33 -0
  6. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_worker_dispatcher.py +34 -19
  7. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/async_client.py +191 -36
  8. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/async_worker.py +3 -3
  9. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/attribute.py +22 -0
  10. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/channel.py +22 -0
  11. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/client.py +190 -37
  12. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/codec.py +15 -13
  13. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/context.py +10 -0
  14. dex_python_sdk-0.1.4/dex/dexpb/dex_pb2.py +409 -0
  15. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/dexpb/dex_pb2.pyi +113 -30
  16. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/dexpb/dex_pb2_grpc.py +21 -18
  17. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/flow_options.py +3 -5
  18. dex_python_sdk-0.1.4/dex/flow_result.py +127 -0
  19. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/runtime_errors.py +9 -29
  20. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/step.py +16 -7
  21. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/pyproject.toml +1 -1
  22. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/Cargo.toml +3 -1
  23. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/Cargo.toml +1 -0
  24. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +2 -1
  25. dex_python_sdk-0.1.2/dex/dexpb/dex_pb2.py +0 -395
  26. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/LEGACY_NOTICES.md +0 -0
  27. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/LICENSE +0 -0
  28. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_async_value_hydrator.py +0 -0
  29. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_async_worker_dispatcher.py +0 -0
  30. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_async_worker_service.py +0 -0
  31. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_native.pyi +0 -0
  32. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_utils.py +0 -0
  33. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_value_hydrator.py +0 -0
  34. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_value_mapper.py +0 -0
  35. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/_worker_service.py +0 -0
  36. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/blob_cache.py +0 -0
  37. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/client_options.py +0 -0
  38. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/condition.py +0 -0
  39. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/dexpb/__init__.py +0 -0
  40. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/flow.py +0 -0
  41. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/flow_config.py +0 -0
  42. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/flow_info.py +0 -0
  43. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/py.typed +0 -0
  44. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/step_execution.py +0 -0
  45. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/timer.py +0 -0
  46. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/wait.py +0 -0
  47. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/worker.py +0 -0
  48. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/dex/worker_options.py +0 -0
  49. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/Cargo.lock +0 -0
  50. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  51. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  52. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  53. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  54. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  55. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  56. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  57. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  58. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  59. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  60. {dex_python_sdk-0.1.2 → dex_python_sdk-0.1.4}/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.1.2
3
+ Version: 0.1.4
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
@@ -71,7 +71,7 @@ registry = dex.Registry((flow,))
71
71
  ```
72
72
 
73
73
  Registry derives codecs from declared Python types and handler annotations.
74
- Built-in scalar types and dataclasses need no codec arguments. Register an
74
+ Built-in primitive types and dataclasses need no codec arguments. Register an
75
75
  explicit codec only for a custom encoding or a type Registry cannot derive.
76
76
  `PersistenceSchema.of(...)` accepts attributes and channels together and
77
77
  partitions them by definition type.
@@ -131,10 +131,49 @@ Applications implement two generic interfaces from [`dex`](dex/):
131
131
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
132
132
  two handler calls. Timer and channel conditions determine how long a Step waits.
133
133
 
134
+ `wait_for_retry` and `execute_retry` limit one logical handler execution. With
135
+ `StepDurability.ASYNC`, local and fallback regular activities share maximum
136
+ attempts, total duration, and 1-based attempt numbers. Fallback starts
137
+ immediately; later regular retries continue the backoff sequence at the
138
+ cumulative attempt.
139
+
134
140
  `Registry` validates every Flow, Step, RPC signature, durable name, lock, and
135
141
  codec before Client or Worker startup. `Client` methods use these typed objects
136
142
  instead of raw Flow, Step, or RPC strings.
137
143
 
144
+ ### Waiting and map inspection
145
+
146
+ `Wait.all_of` and `Wait.any_of` may use unnamed Conditions. Every Condition in
147
+ `Wait.any_combination_of` must have a non-empty user ID; the same Condition
148
+ instance may appear in multiple combinations.
149
+
150
+ Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
151
+ overloads of `wait_for_attribute_equal`. They target the current run and accept
152
+ only string, bool, int, or float wire values. JSON objects, bytes, and null fail
153
+ before transport. `AttributeMap.get_map_size/get_all_instance_keys` include
154
+ buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
155
+ include buffered publishes, and omit empty instances. Keys are decoded and
156
+ sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
157
+
158
+ `Client.wait_for_flow` and `AsyncClient.wait_for_flow` return a
159
+ `WaitForFlowResult` after hydrating every output-bearing completion. Use
160
+ `single_output` only when the Flow contract produces exactly one output:
161
+
162
+ ```python
163
+ output = client.wait_for_flow(flow_id).single_output(OrderResult)
164
+
165
+ result = client.wait_for_flow(flow_id)
166
+ for completion in result.completions:
167
+ if completion.step_execution_id == expected_execution_id:
168
+ output = completion.decode(OrderResult)
169
+ ```
170
+
171
+ `completions` is an immutable tuple in server collection order. Parallel branch
172
+ order is not deterministic, so select by `step_type` or `step_execution_id`.
173
+ No-output Flows return an empty tuple; `single_output` raises `ValueError` for
174
+ zero or multiple completions. `FlowUncompletedError.completions` uses the same
175
+ hydrated model for partial outputs.
176
+
138
177
  ### Errors
139
178
 
140
179
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
@@ -185,22 +224,17 @@ implemented. Python owns its gRPC transport; the native bridge is limited to
185
224
  the shared BlobCache. Design notes:
186
225
  [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
187
226
 
188
- ## Running dex-server locally
227
+ ## Running Dex locally
189
228
 
190
- ### Option 1: use docker compose
191
- See [dex README](https://github.com/superdurable/dex#using-docker-image--docker-compose)
229
+ Install and start the complete local environment with `dexcli`:
192
230
 
193
- ### Option 2: VSCode Dev Container
231
+ ```bash
232
+ brew install superdurable/tap/dexcli
233
+ dexcli dev
234
+ ```
194
235
 
195
- Dev Container is an easy way to get dex-server running locally. Follow these steps to launch a dev container:
196
- - Install Docker, VSCode, and [VSCode Dev Container plugin](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
197
- - Open the project in VSCode.
198
- ```bash
199
- cd dex-python-sdk
200
- code .
201
- ```
202
- - Launch the Remote-Containers: Reopen in Container command from Command Palette (Ctrl + Shift + P). You can also click in the bottom left corner to access the remote container menu.
203
- - Once the dev container starts, dex-server will be listening on port 8801.
236
+ Dex Server listens on `127.0.0.1:8801`. See the
237
+ [CLI README](../cli/README.md) for endpoints and persistence options.
204
238
 
205
239
  ## How To Contribute
206
240
 
@@ -55,7 +55,7 @@ registry = dex.Registry((flow,))
55
55
  ```
56
56
 
57
57
  Registry derives codecs from declared Python types and handler annotations.
58
- Built-in scalar types and dataclasses need no codec arguments. Register an
58
+ Built-in primitive types and dataclasses need no codec arguments. Register an
59
59
  explicit codec only for a custom encoding or a type Registry cannot derive.
60
60
  `PersistenceSchema.of(...)` accepts attributes and channels together and
61
61
  partitions them by definition type.
@@ -115,10 +115,49 @@ Applications implement two generic interfaces from [`dex`](dex/):
115
115
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
116
116
  two handler calls. Timer and channel conditions determine how long a Step waits.
117
117
 
118
+ `wait_for_retry` and `execute_retry` limit one logical handler execution. With
119
+ `StepDurability.ASYNC`, local and fallback regular activities share maximum
120
+ attempts, total duration, and 1-based attempt numbers. Fallback starts
121
+ immediately; later regular retries continue the backoff sequence at the
122
+ cumulative attempt.
123
+
118
124
  `Registry` validates every Flow, Step, RPC signature, durable name, lock, and
119
125
  codec before Client or Worker startup. `Client` methods use these typed objects
120
126
  instead of raw Flow, Step, or RPC strings.
121
127
 
128
+ ### Waiting and map inspection
129
+
130
+ `Wait.all_of` and `Wait.any_of` may use unnamed Conditions. Every Condition in
131
+ `Wait.any_combination_of` must have a non-empty user ID; the same Condition
132
+ instance may appear in multiple combinations.
133
+
134
+ Both `Client` and `AsyncClient` provide singleton and AttributeMap-instance
135
+ overloads of `wait_for_attribute_equal`. They target the current run and accept
136
+ only string, bool, int, or float wire values. JSON objects, bytes, and null fail
137
+ before transport. `AttributeMap.get_map_size/get_all_instance_keys` include
138
+ buffered sets and deletes. The matching `ChannelMap` methods are RPC-only,
139
+ include buffered publishes, and omit empty instances. Keys are decoded and
140
+ sorted. Use `force_complete_if_channels_empty(...)` for conditional completion.
141
+
142
+ `Client.wait_for_flow` and `AsyncClient.wait_for_flow` return a
143
+ `WaitForFlowResult` after hydrating every output-bearing completion. Use
144
+ `single_output` only when the Flow contract produces exactly one output:
145
+
146
+ ```python
147
+ output = client.wait_for_flow(flow_id).single_output(OrderResult)
148
+
149
+ result = client.wait_for_flow(flow_id)
150
+ for completion in result.completions:
151
+ if completion.step_execution_id == expected_execution_id:
152
+ output = completion.decode(OrderResult)
153
+ ```
154
+
155
+ `completions` is an immutable tuple in server collection order. Parallel branch
156
+ order is not deterministic, so select by `step_type` or `step_execution_id`.
157
+ No-output Flows return an empty tuple; `single_output` raises `ValueError` for
158
+ zero or multiple completions. `FlowUncompletedError.completions` uses the same
159
+ hydrated model for partial outputs.
160
+
122
161
  ### Errors
123
162
 
124
163
  Client calls raise concrete `DexServiceError` subclasses. Existing-Flow reads
@@ -169,22 +208,17 @@ implemented. Python owns its gRPC transport; the native bridge is limited to
169
208
  the shared BlobCache. Design notes:
170
209
  [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
171
210
 
172
- ## Running dex-server locally
211
+ ## Running Dex locally
173
212
 
174
- ### Option 1: use docker compose
175
- See [dex README](https://github.com/superdurable/dex#using-docker-image--docker-compose)
213
+ Install and start the complete local environment with `dexcli`:
176
214
 
177
- ### Option 2: VSCode Dev Container
215
+ ```bash
216
+ brew install superdurable/tap/dexcli
217
+ dexcli dev
218
+ ```
178
219
 
179
- Dev Container is an easy way to get dex-server running locally. Follow these steps to launch a dev container:
180
- - Install Docker, VSCode, and [VSCode Dev Container plugin](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
181
- - Open the project in VSCode.
182
- ```bash
183
- cd dex-python-sdk
184
- code .
185
- ```
186
- - Launch the Remote-Containers: Reopen in Container command from Command Palette (Ctrl + Shift + P). You can also click in the bottom left corner to access the remote container menu.
187
- - Once the dev container starts, dex-server will be listening on port 8801.
220
+ Dex Server listens on `127.0.0.1:8801`. See the
221
+ [CLI README](../cli/README.md) for endpoints and persistence options.
188
222
 
189
223
  ## How To Contribute
190
224
 
@@ -8,6 +8,8 @@
8
8
  # Legacy Materials remain under their original licenses.
9
9
  # See LICENSE and LEGACY_NOTICES.md.
10
10
 
11
+ from dex.async_client import AsyncClient
12
+ from dex.async_worker import AsyncWorker
11
13
  from dex.attribute import (
12
14
  Attribute,
13
15
  AttributeIndex,
@@ -17,8 +19,6 @@ from dex.attribute import (
17
19
  )
18
20
  from dex.blob_cache import BlobCache, BlobCacheConfig, open_blob_cache
19
21
  from dex.channel import Channel, ChannelMap
20
- from dex.async_client import AsyncClient
21
- from dex.async_worker import AsyncWorker
22
22
  from dex.client import Client
23
23
  from dex.client_options import ClientOptions
24
24
  from dex.codec import (
@@ -52,6 +52,7 @@ from dex.flow_options import (
52
52
  StopFlowOptions,
53
53
  StopType,
54
54
  )
55
+ from dex.flow_result import StepCompletion, WaitForFlowResult
55
56
  from dex.runtime_errors import (
56
57
  DexServiceError,
57
58
  ErrorSubStatus,
@@ -78,7 +79,7 @@ from dex.step import (
78
79
  WaitForFailurePolicy,
79
80
  dead_end,
80
81
  force_complete,
81
- force_complete_when_channels_empty,
82
+ force_complete_if_channels_empty,
82
83
  force_fail,
83
84
  go_to,
84
85
  go_to_multi,
@@ -140,6 +141,7 @@ __all__ = [
140
141
  "RetryPolicy",
141
142
  "SearchFlowEntry",
142
143
  "SearchFlowsPage",
144
+ "StepCompletion",
143
145
  "StartFlowOptions",
144
146
  "StepExecutionId",
145
147
  "StepDecision",
@@ -156,6 +158,7 @@ __all__ = [
156
158
  "ValueMappingError",
157
159
  "Wait",
158
160
  "WaitForFailurePolicy",
161
+ "WaitForFlowResult",
159
162
  "WireKind",
160
163
  "Worker",
161
164
  "WorkerInvocationError",
@@ -163,7 +166,7 @@ __all__ = [
163
166
  "WorkerTarget",
164
167
  "dead_end",
165
168
  "force_complete",
166
- "force_complete_when_channels_empty",
169
+ "force_complete_if_channels_empty",
167
170
  "force_fail",
168
171
  "graceful_complete",
169
172
  "go_to",
@@ -36,12 +36,12 @@ def translate_rpc_error(
36
36
  flow_id: str | None,
37
37
  requirement: FlowTargetRequirement,
38
38
  ) -> DexServiceError:
39
- details: pb.ErrorResponse | None = None
39
+ details: pb.ServiceErrorResponse | None = None
40
40
  try:
41
41
  status = rpc_status.from_call(cast(grpc.Call, error))
42
42
  if status is not None:
43
43
  for packed in status.details:
44
- candidate = pb.ErrorResponse()
44
+ candidate = pb.ServiceErrorResponse()
45
45
  if packed.Is(candidate.DESCRIPTOR):
46
46
  packed.Unpack(candidate)
47
47
  details = candidate
@@ -87,7 +87,7 @@ def translate_rpc_error(
87
87
  return DexServiceError(*parameters)
88
88
 
89
89
 
90
- def _worker_code(details: pb.ErrorResponse | None) -> grpc.StatusCode | None:
90
+ def _worker_code(details: pb.ServiceErrorResponse | None) -> grpc.StatusCode | None:
91
91
  if details is None or details.original_worker_error_status == 0:
92
92
  return None
93
93
  return next(
@@ -10,6 +10,7 @@ from __future__ import annotations
10
10
 
11
11
  from enum import Enum
12
12
  from typing import Any, Sequence, TypeVar, cast
13
+ from urllib.parse import unquote
13
14
 
14
15
  from dex._utils import require_name
15
16
  from dex._value_mapper import ValueMapper
@@ -204,6 +205,38 @@ class InvocationContext:
204
205
  size=(current.size if current is not None else 0) + 1
205
206
  )
206
207
 
208
+ def _attribute_map_keys(
209
+ self,
210
+ definition: AttributeMap[object],
211
+ ) -> tuple[str, ...]:
212
+ self._require_registered(definition)
213
+ prefix = f"{definition.name}/"
214
+ physical_keys = {key for key in self._attributes if key.startswith(prefix)}
215
+ for key, write in self.attribute_writes.items():
216
+ if not key.startswith(prefix):
217
+ continue
218
+ if write.value.WhichOneof("kind") == "null_value":
219
+ physical_keys.discard(key)
220
+ else:
221
+ physical_keys.add(key)
222
+ return tuple(sorted(unquote(key[len(prefix) :]) for key in physical_keys))
223
+
224
+ def _channel_map_keys(
225
+ self,
226
+ definition: ChannelMap[object],
227
+ ) -> tuple[str, ...]:
228
+ self._require_registered(definition)
229
+ if self._method is not InvocationMethod.RPC:
230
+ raise ValueError("ChannelMap introspection requires an RPC invocation")
231
+ prefix = f"{definition.name}/"
232
+ return tuple(
233
+ sorted(
234
+ unquote(key[len(prefix) :])
235
+ for key, info in self._channel_infos.items()
236
+ if key.startswith(prefix) and info.size > 0
237
+ )
238
+ )
239
+
207
240
  def _channel_size(
208
241
  self,
209
242
  definition: Channel[object] | ChannelMap[object],
@@ -32,8 +32,6 @@ from dex.step import (
32
32
  )
33
33
  from dex.wait import Wait, WaitKind
34
34
 
35
- _INTERNAL_CONDITION_PREFIX = "__dex_internal_condition_"
36
-
37
35
 
38
36
  class WorkerDispatcher:
39
37
  def __init__(
@@ -259,7 +257,8 @@ class WorkerDispatcher:
259
257
  for combination in wait.combinations:
260
258
  waiting.condition_combinations.add(
261
259
  condition_ids=[
262
- mapper.add(condition) for condition in combination.conditions
260
+ mapper.add(condition, id_required=True)
261
+ for condition in combination.conditions
263
262
  ]
264
263
  )
265
264
  else:
@@ -280,11 +279,19 @@ class WorkerDispatcher:
280
279
  mapped.next_steps.extend(self._map_movements(flow, decision.movements))
281
280
  elif decision.kind is DecisionKind.GRACEFUL_COMPLETE:
282
281
  mapped.close_decision.CopyFrom(
283
- self._close(pb.CLOSE_DECISION_TYPE_GRACEFUL_COMPLETE, decision.output)
282
+ self._close(
283
+ pb.CLOSE_DECISION_TYPE_GRACEFUL_COMPLETE,
284
+ decision.output,
285
+ decision._has_output(),
286
+ )
284
287
  )
285
288
  elif decision.kind is DecisionKind.FORCE_COMPLETE:
286
289
  mapped.close_decision.CopyFrom(
287
- self._close(pb.CLOSE_DECISION_TYPE_FORCE_COMPLETE, decision.output)
290
+ self._close(
291
+ pb.CLOSE_DECISION_TYPE_FORCE_COMPLETE,
292
+ decision.output,
293
+ decision._has_output(),
294
+ )
288
295
  )
289
296
  elif decision.kind is DecisionKind.FORCE_FAIL:
290
297
  mapped.close_decision.CopyFrom(
@@ -315,11 +322,16 @@ class WorkerDispatcher:
315
322
  raise ValueError("unsupported StepDecision kind")
316
323
  return mapped
317
324
 
318
- def _close(self, close_type: int, output: object) -> pb.CloseDecision:
319
- return pb.CloseDecision(
320
- close_decision_type=cast(Any, close_type),
321
- close_input=self._values.encode_dynamic(output),
322
- )
325
+ def _close(
326
+ self,
327
+ close_type: int,
328
+ output: object,
329
+ has_output: bool = True,
330
+ ) -> pb.CloseDecision:
331
+ close = pb.CloseDecision(close_decision_type=cast(Any, close_type))
332
+ if has_output:
333
+ close.close_input.CopyFrom(self._values.encode_dynamic(output))
334
+ return close
323
335
 
324
336
  def _map_movements(
325
337
  self,
@@ -417,7 +429,6 @@ class _ConditionMapper:
417
429
  self._flow = flow
418
430
  self._ids: dict[int, str] = {}
419
431
  self._used: set[str] = set()
420
- self._next_id = 0
421
432
  self.timers: list[pb.TimerCondition] = []
422
433
  self.channels: list[pb.ChannelCondition] = []
423
434
 
@@ -427,18 +438,22 @@ class _ConditionMapper:
427
438
  for condition in conditions:
428
439
  self.add(condition)
429
440
 
430
- def add(self, condition: Condition) -> str:
441
+ def add(self, condition: Condition, *, id_required: bool = False) -> str:
431
442
  identity = id(condition)
432
443
  existing = self._ids.get(identity)
433
444
  if existing is not None:
434
445
  return existing
435
- condition_id = condition.condition_id
436
- if condition_id is None:
437
- condition_id = f"{_INTERNAL_CONDITION_PREFIX}{self._next_id}"
438
- self._next_id += 1
439
- if not condition_id or condition_id in self._used:
440
- raise ValueError("duplicate or empty Condition ID")
441
- self._used.add(condition_id)
446
+ condition_id = condition.condition_id or ""
447
+ if id_required and not condition_id:
448
+ raise ValueError(
449
+ "any_combination_of requires every Condition to have an ID"
450
+ )
451
+ if condition.condition_id is not None and not condition_id:
452
+ raise ValueError("empty Condition ID")
453
+ if condition_id and condition_id in self._used:
454
+ raise ValueError("duplicate Condition ID")
455
+ if condition_id:
456
+ self._used.add(condition_id)
442
457
  if isinstance(condition, TimerCondition):
443
458
  seconds = condition.duration.total_seconds()
444
459
  if not seconds.is_integer():