dex-python-sdk 0.0.2__tar.gz → 0.1.1__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 (138) hide show
  1. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/PKG-INFO +58 -19
  2. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/README.md +57 -17
  3. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/__init__.py +12 -1
  4. dex_python_sdk-0.1.1/dex/_async_value_hydrator.py +223 -0
  5. dex_python_sdk-0.1.1/dex/_async_worker_dispatcher.py +139 -0
  6. dex_python_sdk-0.1.1/dex/_async_worker_service.py +65 -0
  7. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/_grpc_errors.py +16 -3
  8. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/_value_mapper.py +23 -0
  9. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/_worker_dispatcher.py +13 -0
  10. dex_python_sdk-0.1.1/dex/async_client.py +757 -0
  11. dex_python_sdk-0.1.1/dex/async_worker.py +106 -0
  12. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/client.py +43 -1
  13. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/flow.py +40 -8
  14. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/flow_info.py +8 -2
  15. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/pyproject.toml +12 -3
  16. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/Cargo.lock +159 -0
  17. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/Cargo.toml +3 -0
  18. dex_python_sdk-0.0.2/dex/command_request.py +0 -120
  19. dex_python_sdk-0.0.2/dex/command_results.py +0 -107
  20. dex_python_sdk-0.0.2/dex/communication.py +0 -136
  21. dex_python_sdk-0.0.2/dex/communication_schema.py +0 -54
  22. dex_python_sdk-0.0.2/dex/data_attributes.py +0 -70
  23. dex_python_sdk-0.0.2/dex/errors.py +0 -109
  24. dex_python_sdk-0.0.2/dex/object_encoder.py +0 -799
  25. dex_python_sdk-0.0.2/dex/persistence.py +0 -89
  26. dex_python_sdk-0.0.2/dex/persistence_options.py +0 -12
  27. dex_python_sdk-0.0.2/dex/persistence_schema.py +0 -51
  28. dex_python_sdk-0.0.2/dex/registry.py +0 -204
  29. dex_python_sdk-0.0.2/dex/reset_workflow_type_and_options.py +0 -67
  30. dex_python_sdk-0.0.2/dex/rpc.py +0 -93
  31. dex_python_sdk-0.0.2/dex/search_attributes.py +0 -184
  32. dex_python_sdk-0.0.2/dex/state_decision.py +0 -153
  33. dex_python_sdk-0.0.2/dex/state_execution_locals.py +0 -66
  34. dex_python_sdk-0.0.2/dex/state_movement.py +0 -115
  35. dex_python_sdk-0.0.2/dex/state_schema.py +0 -48
  36. dex_python_sdk-0.0.2/dex/stop_workflow_options.py +0 -18
  37. dex_python_sdk-0.0.2/dex/tests/__init__.py +0 -80
  38. dex_python_sdk-0.0.2/dex/tests/dex-service-env/docker-compose-init.sh +0 -44
  39. dex_python_sdk-0.0.2/dex/tests/dex-service-env/docker-compose.yml +0 -97
  40. dex_python_sdk-0.0.2/dex/tests/dex-service-env/dynamicconfig/README.md +0 -39
  41. dex_python_sdk-0.0.2/dex/tests/dex-service-env/dynamicconfig/development-sql.yaml +0 -9
  42. dex_python_sdk-0.0.2/dex/tests/dex-service-env/dynamicconfig/docker.yaml +0 -2
  43. dex_python_sdk-0.0.2/dex/tests/test_abnormal_exit_workflow.py +0 -43
  44. dex_python_sdk-0.0.2/dex/tests/test_basic_workflow.py +0 -70
  45. dex_python_sdk-0.0.2/dex/tests/test_conditional_complete.py +0 -50
  46. dex_python_sdk-0.0.2/dex/tests/test_describe_workflow.py +0 -40
  47. dex_python_sdk-0.0.2/dex/tests/test_empty_data_decodes_properly.py +0 -74
  48. dex_python_sdk-0.0.2/dex/tests/test_internal_channel.py +0 -28
  49. dex_python_sdk-0.0.2/dex/tests/test_internal_channel_with_no_prefix_channel.py +0 -41
  50. dex_python_sdk-0.0.2/dex/tests/test_persistence_data_attributes.py +0 -62
  51. dex_python_sdk-0.0.2/dex/tests/test_persistence_search_attributes.py +0 -127
  52. dex_python_sdk-0.0.2/dex/tests/test_persistence_state_execution_locals.py +0 -38
  53. dex_python_sdk-0.0.2/dex/tests/test_rpc.py +0 -64
  54. dex_python_sdk-0.0.2/dex/tests/test_rpc_with_memo.py +0 -195
  55. dex_python_sdk-0.0.2/dex/tests/test_rpc_with_memo_duplicate_java_tests.py +0 -117
  56. dex_python_sdk-0.0.2/dex/tests/test_signal.py +0 -51
  57. dex_python_sdk-0.0.2/dex/tests/test_skip_wait_until.py +0 -76
  58. dex_python_sdk-0.0.2/dex/tests/test_state_failure_recovery.py +0 -28
  59. dex_python_sdk-0.0.2/dex/tests/test_timer.py +0 -35
  60. dex_python_sdk-0.0.2/dex/tests/test_wait_for_state_execution_completion.py +0 -53
  61. dex_python_sdk-0.0.2/dex/tests/test_workflow_errors.py +0 -87
  62. dex_python_sdk-0.0.2/dex/tests/test_workflow_state_options.py +0 -118
  63. dex_python_sdk-0.0.2/dex/tests/test_workflow_state_options_override.py +0 -44
  64. dex_python_sdk-0.0.2/dex/tests/worker_server.py +0 -64
  65. dex_python_sdk-0.0.2/dex/tests/workflows/abnormal_exit_workflow.py +0 -42
  66. dex_python_sdk-0.0.2/dex/tests/workflows/basic_workflow.py +0 -62
  67. dex_python_sdk-0.0.2/dex/tests/workflows/conditional_complete_workflow.py +0 -95
  68. dex_python_sdk-0.0.2/dex/tests/workflows/describe_workflow.py +0 -46
  69. dex_python_sdk-0.0.2/dex/tests/workflows/empty_data_workflow.py +0 -45
  70. dex_python_sdk-0.0.2/dex/tests/workflows/internal_channel_workflow.py +0 -129
  71. dex_python_sdk-0.0.2/dex/tests/workflows/internal_channel_workflow_with_no_prefix_channel.py +0 -100
  72. dex_python_sdk-0.0.2/dex/tests/workflows/java_duplicate_rpc_memo_workflow.py +0 -276
  73. dex_python_sdk-0.0.2/dex/tests/workflows/persistence_data_attributes_workflow.py +0 -98
  74. dex_python_sdk-0.0.2/dex/tests/workflows/persistence_search_attributes_workflow.py +0 -159
  75. dex_python_sdk-0.0.2/dex/tests/workflows/persistence_state_execution_local_workflow.py +0 -63
  76. dex_python_sdk-0.0.2/dex/tests/workflows/recovery_workflow.py +0 -82
  77. dex_python_sdk-0.0.2/dex/tests/workflows/rpc_memo_workflow.py +0 -231
  78. dex_python_sdk-0.0.2/dex/tests/workflows/rpc_workflow.py +0 -117
  79. dex_python_sdk-0.0.2/dex/tests/workflows/state_options_override_workflow.py +0 -93
  80. dex_python_sdk-0.0.2/dex/tests/workflows/state_options_workflow.py +0 -84
  81. dex_python_sdk-0.0.2/dex/tests/workflows/timer_workflow.py +0 -46
  82. dex_python_sdk-0.0.2/dex/tests/workflows/wait_for_state_with_state_execution_id_workflow.py +0 -70
  83. dex_python_sdk-0.0.2/dex/tests/workflows/wait_for_state_with_wait_for_key_workflow.py +0 -71
  84. dex_python_sdk-0.0.2/dex/tests/workflows/wait_internal_channel_workflow.py +0 -47
  85. dex_python_sdk-0.0.2/dex/tests/workflows/wait_signal_workflow.py +0 -147
  86. dex_python_sdk-0.0.2/dex/type_store.py +0 -99
  87. dex_python_sdk-0.0.2/dex/unregistered_client.py +0 -585
  88. dex_python_sdk-0.0.2/dex/utils/__init__.py +0 -3
  89. dex_python_sdk-0.0.2/dex/utils/dex_typing.py +0 -25
  90. dex_python_sdk-0.0.2/dex/utils/persistence_utils.py +0 -32
  91. dex_python_sdk-0.0.2/dex/worker_service.py +0 -432
  92. dex_python_sdk-0.0.2/dex/workflow.py +0 -79
  93. dex_python_sdk-0.0.2/dex/workflow_context.py +0 -44
  94. dex_python_sdk-0.0.2/dex/workflow_info.py +0 -16
  95. dex_python_sdk-0.0.2/dex/workflow_options.py +0 -74
  96. dex_python_sdk-0.0.2/dex/workflow_state.py +0 -123
  97. dex_python_sdk-0.0.2/dex/workflow_state_options.py +0 -154
  98. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/LEGACY_NOTICES.md +0 -0
  99. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/LICENSE +0 -0
  100. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/_invocation_context.py +0 -0
  101. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/_native.pyi +0 -0
  102. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/_utils.py +0 -0
  103. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/_value_hydrator.py +0 -0
  104. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/_worker_service.py +0 -0
  105. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/attribute.py +0 -0
  106. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/blob_cache.py +0 -0
  107. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/channel.py +0 -0
  108. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/client_options.py +0 -0
  109. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/codec.py +0 -0
  110. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/condition.py +0 -0
  111. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/context.py +0 -0
  112. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/dexpb/__init__.py +0 -0
  113. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/dexpb/dex_pb2.py +0 -0
  114. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/dexpb/dex_pb2.pyi +0 -0
  115. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/dexpb/dex_pb2_grpc.py +0 -0
  116. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/flow_config.py +0 -0
  117. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/flow_options.py +0 -0
  118. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/py.typed +0 -0
  119. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/runtime_errors.py +0 -0
  120. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/step.py +0 -0
  121. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/step_execution.py +0 -0
  122. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/timer.py +0 -0
  123. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/wait.py +0 -0
  124. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/worker.py +0 -0
  125. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/dex/worker_options.py +0 -0
  126. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/Cargo.toml +0 -0
  127. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/LICENSE +0 -0
  128. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/src/config.rs +0 -0
  129. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/src/entry.rs +0 -0
  130. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/src/error.rs +0 -0
  131. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/src/format.rs +0 -0
  132. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/src/lib.rs +0 -0
  133. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/src/policy.rs +0 -0
  134. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/src/store.rs +0 -0
  135. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache/tests/blob_cache_integration.rs +0 -0
  136. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache-python/Cargo.toml +0 -0
  137. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache-python/LICENSE +0 -0
  138. {dex_python_sdk-0.0.2 → dex_python_sdk-0.1.1}/sdk-rust/crates/dex-blob-cache-python/src/lib.rs +0 -0
@@ -1,9 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dex-python-sdk
3
- Version: 0.0.2
3
+ Version: 0.1.1
4
4
  Requires-Dist: grpcio>=1.83.0
5
5
  Requires-Dist: grpcio-status>=1.83.0
6
- Requires-Dist: httpx==0.28.1
7
6
  Requires-Dist: protobuf>=7.35.1
8
7
  License-File: LICENSE
9
8
  License-File: LEGACY_NOTICES.md
@@ -88,7 +87,7 @@ options = (
88
87
  ```
89
88
 
90
89
  ```
91
- pip install dex-python-sdk==0.0.2
90
+ pip install dex-python-sdk==0.1.0
92
91
  ```
93
92
 
94
93
  See [samples](../examples/python) for use case examples.
@@ -100,14 +99,16 @@ See [samples](../examples/python) for use case examples.
100
99
 
101
100
  ## Concepts
102
101
 
103
- Applications implement two generic interfaces from [`dex.contracts`](dex/contracts/):
102
+ Applications implement two generic interfaces from [`dex`](dex/):
104
103
 
105
104
  - `Flow[START_INPUT]` returns `StepList.start_step(...)`, followed by optional
106
105
  `.other_steps(...)`, from one `get_steps()` method. The `StepList` generic
107
106
  binds the Flow input to the starting Step input. Use `StepList.empty()` when
108
107
  a Flow has no Steps.
109
- - `Step[INPUT]` implements synchronous `execute` and optionally synchronous
110
- `wait_for`.
108
+ - `Step[INPUT]` implements `execute` and optionally `wait_for`. The default
109
+ Worker path requires synchronous handlers. With `AsyncWorker` and
110
+ `Registry(..., allow_async_handlers=True)`, handlers may be `async def` and
111
+ `await` an `AsyncClient`.
111
112
 
112
113
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
113
114
  two handler calls. Timer and channel conditions determine how long a Step waits.
@@ -116,16 +117,30 @@ two handler calls. Timer and channel conditions determine how long a Step waits.
116
117
  codec before Client or Worker startup. `Client` methods use these typed objects
117
118
  instead of raw Flow, Step, or RPC strings.
118
119
 
119
- The legacy IWF integration inventory is ported under
120
- [`tests/integ`](tests/integ/README.md). Its 58 executable scenarios
121
- exercise the same workflows, client operations, and assertions as the Java
122
- suite against an isolated `dexcli dev` environment.
120
+ ### Sync vs asyncio
121
+
122
+ - **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
123
+ Worker. Blocking `Client` calls inside `Step.execute` are safe (one pool
124
+ thread is occupied; other RPCs still run).
125
+ - **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
126
+ `Registry(..., allow_async_handlers=True)` when Steps/RPCs are coroutines.
127
+ Inside async `execute`, inject `AsyncClient` — do not call sync `Client` on
128
+ the Worker event loop. Sync `Worker` still rejects coroutine handlers at
129
+ registry construction unless `allow_async_handlers=True` (and even then the
130
+ sync Worker dispatcher rejects awaitable return values).
131
+
132
+ Integration scenarios live under
133
+ [`tests/integ`](tests/integ/README.md). They exercise the same workflows,
134
+ client operations, and assertions as the Java suite against an isolated
135
+ `dexcli dev` environment.
123
136
 
124
137
  ## Implementation status
125
138
 
126
- The strongly typed contracts, registry, synchronous Client, Worker gRPC
127
- service, and Rust-backed BlobCache are implemented. Python owns its gRPC
128
- transport; the native bridge is limited to the shared BlobCache.
139
+ The strongly typed contracts, registry, synchronous Client/Worker, optional
140
+ `AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
141
+ implemented. Python owns its gRPC transport; the native bridge is limited to
142
+ the shared BlobCache. Design notes:
143
+ [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
129
144
 
130
145
  ## Running dex-server locally
131
146
 
@@ -162,6 +177,25 @@ environment:
162
177
  ./run-integration-tests.sh
163
178
  ```
164
179
 
180
+ ### Measure integration coverage
181
+
182
+ Run the same integration suite with Python source coverage:
183
+
184
+ ```bash
185
+ ./run-integration-tests.sh --coverage
186
+ ```
187
+
188
+ Only the integration scenarios contribute execution data, and only production
189
+ Python modules under `dex` are measured. Generated protobuf modules under
190
+ `dex/dexpb` are excluded. The
191
+ terminal report lists uncovered line ranges. The browser report starts at
192
+ `coverage/html/index.html`; `coverage/coverage.xml` and `coverage/lcov.info`
193
+ are also generated.
194
+
195
+ CI uploads LCOV to Codecov with GitHub OIDC under the
196
+ `sdk-python-integration` flag and retains the full report as the
197
+ `sdk-python-integration-coverage` Actions artifact.
198
+
165
199
  #### Update IDL
166
200
 
167
201
  Edit [`protos/dex.proto`](../protos/dex.proto). Rename catalog: [`docs/design/idl-renames.md`](../docs/design/idl-renames.md).
@@ -186,12 +220,17 @@ This project is governed by the [Contributor Covenant v 1.4.1](CODE_OF_CONDUCT.m
186
220
 
187
221
  ## Publishing to PyPI
188
222
 
189
- 1. Bump `version` in `pyproject.toml`, refresh `uv.lock`, and update the `pip install` line above.
190
- 2. Run **Publish Python SDK to PyPI** manually without `publish` to validate all distributions.
191
- 3. Create a GitHub Release with tag `sdk-python/vX.Y.Z` (for example `sdk-python/v0.0.2`).
192
- 4. CI builds and smoke-tests Linux x86_64/ARM64, macOS x86_64/ARM64, and Windows x86_64 wheels, verifies the source distribution, and publishes them with `PYPI_TOKEN`.
193
-
194
- A manual run publishes only from `main`, when its version matches `pyproject.toml` and `publish` is explicitly selected.
223
+ 1. Optionally run **Publish Python SDK to PyPI** via workflow_dispatch with a version and
224
+ `publish=false` to validate all distributions without uploading.
225
+ 2. Create a GitHub Release with tag `sdk-python/vX.Y.Z` (for example `sdk-python/v0.1.0`).
226
+ CI stamps that version into `pyproject.toml` for the build (same idea as the TypeScript
227
+ SDK release), then builds and smoke-tests Linux x86_64/ARM64, macOS x86_64/ARM64, and
228
+ Windows x86_64 wheels, verifies the source distribution, and publishes with `PYPI_TOKEN`.
229
+ 3. After publishing, bump the committed `pyproject.toml` / docs install line when you want
230
+ the repo tip to reflect the released version.
231
+
232
+ A manual run publishes only from `main`, and only when `publish` is explicitly selected.
233
+ The dispatch `version` input is stamped the same way as a release tag.
195
234
 
196
235
  See [CONTRIBUTING.md](../CONTRIBUTING.md#releases-monorepo-tags) for monorepo tag conventions.
197
236
 
@@ -71,7 +71,7 @@ options = (
71
71
  ```
72
72
 
73
73
  ```
74
- pip install dex-python-sdk==0.0.2
74
+ pip install dex-python-sdk==0.1.0
75
75
  ```
76
76
 
77
77
  See [samples](../examples/python) for use case examples.
@@ -83,14 +83,16 @@ See [samples](../examples/python) for use case examples.
83
83
 
84
84
  ## Concepts
85
85
 
86
- Applications implement two generic interfaces from [`dex.contracts`](dex/contracts/):
86
+ Applications implement two generic interfaces from [`dex`](dex/):
87
87
 
88
88
  - `Flow[START_INPUT]` returns `StepList.start_step(...)`, followed by optional
89
89
  `.other_steps(...)`, from one `get_steps()` method. The `StepList` generic
90
90
  binds the Flow input to the starting Step input. Use `StepList.empty()` when
91
91
  a Flow has no Steps.
92
- - `Step[INPUT]` implements synchronous `execute` and optionally synchronous
93
- `wait_for`.
92
+ - `Step[INPUT]` implements `execute` and optionally `wait_for`. The default
93
+ Worker path requires synchronous handlers. With `AsyncWorker` and
94
+ `Registry(..., allow_async_handlers=True)`, handlers may be `async def` and
95
+ `await` an `AsyncClient`.
94
96
 
95
97
  `StepOptions.wait_for_method_timeout` and `execute_method_timeout` bound the
96
98
  two handler calls. Timer and channel conditions determine how long a Step waits.
@@ -99,16 +101,30 @@ two handler calls. Timer and channel conditions determine how long a Step waits.
99
101
  codec before Client or Worker startup. `Client` methods use these typed objects
100
102
  instead of raw Flow, Step, or RPC strings.
101
103
 
102
- The legacy IWF integration inventory is ported under
103
- [`tests/integ`](tests/integ/README.md). Its 58 executable scenarios
104
- exercise the same workflows, client operations, and assertions as the Java
105
- suite against an isolated `dexcli dev` environment.
104
+ ### Sync vs asyncio
105
+
106
+ - **Sync (default):** `Client` and `Worker` use blocking gRPC and a thread-pool
107
+ Worker. Blocking `Client` calls inside `Step.execute` are safe (one pool
108
+ thread is occupied; other RPCs still run).
109
+ - **Asyncio:** `AsyncClient` and `AsyncWorker` use `grpc.aio`. Use
110
+ `Registry(..., allow_async_handlers=True)` when Steps/RPCs are coroutines.
111
+ Inside async `execute`, inject `AsyncClient` — do not call sync `Client` on
112
+ the Worker event loop. Sync `Worker` still rejects coroutine handlers at
113
+ registry construction unless `allow_async_handlers=True` (and even then the
114
+ sync Worker dispatcher rejects awaitable return values).
115
+
116
+ Integration scenarios live under
117
+ [`tests/integ`](tests/integ/README.md). They exercise the same workflows,
118
+ client operations, and assertions as the Java suite against an isolated
119
+ `dexcli dev` environment.
106
120
 
107
121
  ## Implementation status
108
122
 
109
- The strongly typed contracts, registry, synchronous Client, Worker gRPC
110
- service, and Rust-backed BlobCache are implemented. Python owns its gRPC
111
- transport; the native bridge is limited to the shared BlobCache.
123
+ The strongly typed contracts, registry, synchronous Client/Worker, optional
124
+ `AsyncClient`/`AsyncWorker` (`grpc.aio`), and Rust-backed BlobCache are
125
+ implemented. Python owns its gRPC transport; the native bridge is limited to
126
+ the shared BlobCache. Design notes:
127
+ [`docs/design/plan/python-sdk-async-apis.md`](../docs/design/plan/python-sdk-async-apis.md).
112
128
 
113
129
  ## Running dex-server locally
114
130
 
@@ -145,6 +161,25 @@ environment:
145
161
  ./run-integration-tests.sh
146
162
  ```
147
163
 
164
+ ### Measure integration coverage
165
+
166
+ Run the same integration suite with Python source coverage:
167
+
168
+ ```bash
169
+ ./run-integration-tests.sh --coverage
170
+ ```
171
+
172
+ Only the integration scenarios contribute execution data, and only production
173
+ Python modules under `dex` are measured. Generated protobuf modules under
174
+ `dex/dexpb` are excluded. The
175
+ terminal report lists uncovered line ranges. The browser report starts at
176
+ `coverage/html/index.html`; `coverage/coverage.xml` and `coverage/lcov.info`
177
+ are also generated.
178
+
179
+ CI uploads LCOV to Codecov with GitHub OIDC under the
180
+ `sdk-python-integration` flag and retains the full report as the
181
+ `sdk-python-integration-coverage` Actions artifact.
182
+
148
183
  #### Update IDL
149
184
 
150
185
  Edit [`protos/dex.proto`](../protos/dex.proto). Rename catalog: [`docs/design/idl-renames.md`](../docs/design/idl-renames.md).
@@ -169,12 +204,17 @@ This project is governed by the [Contributor Covenant v 1.4.1](CODE_OF_CONDUCT.m
169
204
 
170
205
  ## Publishing to PyPI
171
206
 
172
- 1. Bump `version` in `pyproject.toml`, refresh `uv.lock`, and update the `pip install` line above.
173
- 2. Run **Publish Python SDK to PyPI** manually without `publish` to validate all distributions.
174
- 3. Create a GitHub Release with tag `sdk-python/vX.Y.Z` (for example `sdk-python/v0.0.2`).
175
- 4. CI builds and smoke-tests Linux x86_64/ARM64, macOS x86_64/ARM64, and Windows x86_64 wheels, verifies the source distribution, and publishes them with `PYPI_TOKEN`.
176
-
177
- A manual run publishes only from `main`, when its version matches `pyproject.toml` and `publish` is explicitly selected.
207
+ 1. Optionally run **Publish Python SDK to PyPI** via workflow_dispatch with a version and
208
+ `publish=false` to validate all distributions without uploading.
209
+ 2. Create a GitHub Release with tag `sdk-python/vX.Y.Z` (for example `sdk-python/v0.1.0`).
210
+ CI stamps that version into `pyproject.toml` for the build (same idea as the TypeScript
211
+ SDK release), then builds and smoke-tests Linux x86_64/ARM64, macOS x86_64/ARM64, and
212
+ Windows x86_64 wheels, verifies the source distribution, and publishes with `PYPI_TOKEN`.
213
+ 3. After publishing, bump the committed `pyproject.toml` / docs install line when you want
214
+ the repo tip to reflect the released version.
215
+
216
+ A manual run publishes only from `main`, and only when `publish` is explicitly selected.
217
+ The dispatch `version` input is stamped the same way as a release tag.
178
218
 
179
219
  See [CONTRIBUTING.md](../CONTRIBUTING.md#releases-monorepo-tags) for monorepo tag conventions.
180
220
 
@@ -17,6 +17,8 @@ from dex.attribute import (
17
17
  )
18
18
  from dex.blob_cache import BlobCache, BlobCacheConfig, open_blob_cache
19
19
  from dex.channel import Channel, ChannelMap
20
+ from dex.async_client import AsyncClient
21
+ from dex.async_worker import AsyncWorker
20
22
  from dex.client import Client
21
23
  from dex.client_options import ClientOptions
22
24
  from dex.codec import (
@@ -35,7 +37,13 @@ from dex.condition import ConditionCombination
35
37
  from dex.context import Context
36
38
  from dex.flow import Flow, PersistenceSchema, Registry, RPCResult, rpc
37
39
  from dex.flow_config import ActiveStepSearchMode, FlowConfig
38
- from dex.flow_info import FlowInfo, FlowStatus, HealthInfo, SearchFlowEntry
40
+ from dex.flow_info import (
41
+ FlowInfo,
42
+ FlowStatus,
43
+ HealthInfo,
44
+ SearchFlowEntry,
45
+ SearchFlowsPage,
46
+ )
39
47
  from dex.flow_options import (
40
48
  IdReusePolicy,
41
49
  ResetFlowOptions,
@@ -85,6 +93,8 @@ __all__ = [
85
93
  "AttributeIndex",
86
94
  "AttributeLock",
87
95
  "AttributeMap",
96
+ "AsyncClient",
97
+ "AsyncWorker",
88
98
  "BlobCache",
89
99
  "BlobCacheConfig",
90
100
  "Channel",
@@ -115,6 +125,7 @@ __all__ = [
115
125
  "ResetType",
116
126
  "RetryPolicy",
117
127
  "SearchFlowEntry",
128
+ "SearchFlowsPage",
118
129
  "StartFlowOptions",
119
130
  "StepExecutionId",
120
131
  "StepDecision",
@@ -0,0 +1,223 @@
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 logging
12
+ from dataclasses import dataclass, field
13
+
14
+ from dex.blob_cache import BlobCache
15
+ from dex.dexpb import dex_pb2 as pb
16
+ from dex.dexpb import dex_pb2_grpc
17
+
18
+ _LOGGER = logging.getLogger(__name__)
19
+
20
+
21
+ @dataclass
22
+ class _PendingBlob:
23
+ blob_id: str
24
+ is_object: bool
25
+ request: pb.Value
26
+ indexes: list[int] = field(default_factory=list)
27
+ hydrated: pb.Value | None = None
28
+
29
+
30
+ class AsyncValueHydrator:
31
+ def __init__(
32
+ self,
33
+ service: dex_pb2_grpc.FlowServiceStub,
34
+ cache: BlobCache,
35
+ ) -> None:
36
+ self._service = service
37
+ self._cache = cache
38
+
39
+ async def hydrate(self, value: pb.Value) -> pb.Value:
40
+ return (await self.hydrate_all([value]))[0]
41
+
42
+ async def hydrate_all(self, values: list[pb.Value]) -> list[pb.Value]:
43
+ hydrated = list(values)
44
+ pending: dict[tuple[str, bool], _PendingBlob] = {}
45
+ for index, value in enumerate(values):
46
+ key = self._blob_key(value)
47
+ if key is None:
48
+ self._validate_concrete(value)
49
+ continue
50
+ blob = pending.get(key)
51
+ if blob is None:
52
+ blob = _PendingBlob(key[0], key[1], value)
53
+ pending[key] = blob
54
+ blob.indexes.append(index)
55
+
56
+ misses: list[_PendingBlob] = []
57
+ for blob in pending.values():
58
+ concrete = self._read_cache(blob)
59
+ if concrete is None:
60
+ misses.append(blob)
61
+ else:
62
+ blob.hydrated = concrete
63
+ await self._load_misses(misses)
64
+
65
+ for blob in pending.values():
66
+ if blob.hydrated is None:
67
+ raise RuntimeError(f"blob was not hydrated: {blob.blob_id}")
68
+ for index in blob.indexes:
69
+ hydrated[index] = blob.hydrated
70
+ return hydrated
71
+
72
+ async def wait_for_request(
73
+ self,
74
+ request: pb.InvokeWaitForMethodRequest,
75
+ ) -> pb.InvokeWaitForMethodRequest:
76
+ result = pb.InvokeWaitForMethodRequest()
77
+ result.CopyFrom(request)
78
+ values = [request.step_input, *(entry.value for entry in request.attributes)]
79
+ hydrated = await self.hydrate_all(values)
80
+ result.step_input.CopyFrom(hydrated[0])
81
+ for entry, value in zip(result.attributes, hydrated[1:]):
82
+ entry.value.CopyFrom(value)
83
+ return result
84
+
85
+ async def execute_request(
86
+ self,
87
+ request: pb.InvokeExecuteMethodRequest,
88
+ ) -> pb.InvokeExecuteMethodRequest:
89
+ result = pb.InvokeExecuteMethodRequest()
90
+ result.CopyFrom(request)
91
+ values = [request.step_input]
92
+ values.extend(entry.value for entry in request.attributes)
93
+ values.extend(entry.value for entry in request.step_exe_locals)
94
+ for channel_result in request.condition_results.channel_results:
95
+ values.extend(channel_result.values)
96
+ hydrated = iter(await self.hydrate_all(values))
97
+ result.step_input.CopyFrom(next(hydrated))
98
+ for entry in result.attributes:
99
+ entry.value.CopyFrom(next(hydrated))
100
+ for entry in result.step_exe_locals:
101
+ entry.value.CopyFrom(next(hydrated))
102
+ for channel_result in result.condition_results.channel_results:
103
+ for value in channel_result.values:
104
+ value.CopyFrom(next(hydrated))
105
+ return result
106
+
107
+ async def rpc_request(
108
+ self,
109
+ request: pb.InvokeWorkerRPCRequest,
110
+ ) -> pb.InvokeWorkerRPCRequest:
111
+ result = pb.InvokeWorkerRPCRequest()
112
+ result.CopyFrom(request)
113
+ values = [request.input, *(entry.value for entry in request.attributes)]
114
+ hydrated = await self.hydrate_all(values)
115
+ result.input.CopyFrom(hydrated[0])
116
+ for entry, value in zip(result.attributes, hydrated[1:]):
117
+ entry.value.CopyFrom(value)
118
+ return result
119
+
120
+ async def step_outputs(
121
+ self,
122
+ outputs: list[pb.StepCompletionOutput],
123
+ ) -> list[pb.StepCompletionOutput]:
124
+ values = [output.completed_step_output for output in outputs]
125
+ hydrated = await self.hydrate_all(values)
126
+ results: list[pb.StepCompletionOutput] = []
127
+ for output, value in zip(outputs, hydrated):
128
+ result = pb.StepCompletionOutput()
129
+ result.CopyFrom(output)
130
+ result.completed_step_output.CopyFrom(value)
131
+ results.append(result)
132
+ return results
133
+
134
+ async def _load_misses(self, misses: list[_PendingBlob]) -> None:
135
+ if not misses:
136
+ return
137
+ response = await self._service.LoadBlobs(
138
+ pb.LoadBlobsRequest(values=[miss.request for miss in misses])
139
+ )
140
+ for miss in misses:
141
+ concrete = response.values.get(miss.blob_id)
142
+ if concrete is None:
143
+ raise RuntimeError(f"LoadBlobs omitted blob {miss.blob_id}")
144
+ self._validate_hydrated(miss, concrete)
145
+ miss.hydrated = concrete
146
+ self._write_cache(miss, concrete)
147
+
148
+ def _read_cache(self, blob: _PendingBlob) -> pb.Value | None:
149
+ try:
150
+ payload = self._cache.get(blob.blob_id)
151
+ if payload is None:
152
+ return None
153
+ if blob.is_object:
154
+ concrete = pb.Value(obj_value=pb.EncodedObject.FromString(payload))
155
+ else:
156
+ concrete = pb.Value(string_value=payload.decode("utf-8"))
157
+ self._validate_hydrated(blob, concrete)
158
+ return concrete
159
+ except Exception:
160
+ _LOGGER.warning("cannot read cached blob %s", blob.blob_id, exc_info=True)
161
+ try:
162
+ self._cache.delete(blob.blob_id)
163
+ except Exception:
164
+ _LOGGER.warning(
165
+ "cannot delete cached blob %s",
166
+ blob.blob_id,
167
+ exc_info=True,
168
+ )
169
+ return None
170
+
171
+ def _write_cache(self, blob: _PendingBlob, concrete: pb.Value) -> None:
172
+ try:
173
+ payload = (
174
+ concrete.obj_value.SerializeToString()
175
+ if blob.is_object
176
+ else concrete.string_value.encode("utf-8")
177
+ )
178
+ self._cache.put(blob.blob_id, payload)
179
+ except Exception:
180
+ _LOGGER.warning("cannot cache blob %s", blob.blob_id, exc_info=True)
181
+
182
+ @staticmethod
183
+ def _blob_key(value: pb.Value) -> tuple[str, bool] | None:
184
+ kind = value.WhichOneof("kind")
185
+ if kind == "internal_blob_id_for_string_value":
186
+ blob_id = value.internal_blob_id_for_string_value
187
+ if not blob_id:
188
+ raise ValueError("blob ID is required")
189
+ return blob_id, False
190
+ if kind == "internal_blob_id_for_obj_value":
191
+ blob_id = value.internal_blob_id_for_obj_value
192
+ if not blob_id:
193
+ raise ValueError("blob ID is required")
194
+ return blob_id, True
195
+ return None
196
+
197
+ @staticmethod
198
+ def _validate_hydrated(blob: _PendingBlob, value: pb.Value) -> None:
199
+ expected = "obj_value" if blob.is_object else "string_value"
200
+ if value.WhichOneof("kind") != expected:
201
+ raise RuntimeError(
202
+ f"blob {blob.blob_id} hydrated to {value.WhichOneof('kind')}"
203
+ )
204
+ AsyncValueHydrator._validate_concrete(value)
205
+
206
+ @staticmethod
207
+ def _validate_concrete(value: pb.Value) -> None:
208
+ kind = value.WhichOneof("kind")
209
+ if kind in ("string_value", "int_value", "bool_value", "null_value"):
210
+ return
211
+ if kind == "double_value":
212
+ import math
213
+
214
+ if not math.isfinite(value.double_value):
215
+ raise ValueError("non-finite numbers are unsupported")
216
+ return
217
+ if kind == "obj_value":
218
+ if value.obj_value.encoding not in ("json", "rawbytes"):
219
+ raise ValueError(
220
+ f"unsupported object encoding {value.obj_value.encoding}"
221
+ )
222
+ return
223
+ raise ValueError("Value has no concrete kind")
@@ -0,0 +1,139 @@
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 inspect import isawaitable
12
+ from typing import Any
13
+
14
+ from dex._async_value_hydrator import AsyncValueHydrator
15
+ from dex._invocation_context import InvocationContext, InvocationMethod
16
+ from dex._value_mapper import ValueMapper
17
+ from dex._worker_dispatcher import WorkerDispatcher
18
+ from dex.dexpb import dex_pb2 as pb
19
+ from dex.flow import RPCResult, Registry
20
+ from dex.step import StepDecision
21
+ from dex.wait import Wait
22
+
23
+
24
+ class AsyncWorkerDispatcher(WorkerDispatcher):
25
+ def __init__(
26
+ self,
27
+ registry: Registry,
28
+ values: ValueMapper,
29
+ hydrator: AsyncValueHydrator,
30
+ ) -> None:
31
+ self._registry = registry
32
+ self._values = values
33
+ self._hydrator = hydrator # type: ignore[assignment]
34
+
35
+ async def invoke_wait_for( # type: ignore[override]
36
+ self,
37
+ original: pb.InvokeWaitForMethodRequest,
38
+ ) -> pb.InvokeWaitForMethodResponse:
39
+ request = await self._hydrator.wait_for_request(original)
40
+ flow = self._registry._flow_by_type(request.flow_type)
41
+ step = flow.step(request.step_type)
42
+ context = InvocationContext(
43
+ InvocationMethod.WAIT_FOR,
44
+ flow,
45
+ request.context,
46
+ self._values,
47
+ request.attributes,
48
+ )
49
+ input = self._values.decode(request.step_input, step.input_codec)
50
+ wait = step.step.wait_for(context, input)
51
+ if isawaitable(wait):
52
+ wait = await wait
53
+ if not isinstance(wait, Wait):
54
+ raise TypeError("wait_for must return Wait")
55
+ response = pb.InvokeWaitForMethodResponse(
56
+ upsert_attributes=list(context.attribute_writes.values()),
57
+ upsert_step_exe_locals=list(context.local_writes.values()),
58
+ record_events=context.events,
59
+ publish_to_channel=context.publications,
60
+ )
61
+ waiting = self._map_wait(flow, wait)
62
+ if waiting is not None:
63
+ response.waiting_condition.CopyFrom(waiting)
64
+ return response
65
+
66
+ async def invoke_execute( # type: ignore[override]
67
+ self,
68
+ original: pb.InvokeExecuteMethodRequest,
69
+ ) -> pb.InvokeExecuteMethodResponse:
70
+ request = await self._hydrator.execute_request(original)
71
+ flow = self._registry._flow_by_type(request.flow_type)
72
+ step = flow.step(request.step_type)
73
+ condition_results = (
74
+ request.condition_results if request.HasField("condition_results") else None
75
+ )
76
+ context = InvocationContext(
77
+ InvocationMethod.EXECUTE,
78
+ flow,
79
+ request.context,
80
+ self._values,
81
+ request.attributes,
82
+ request.step_exe_locals,
83
+ condition_results,
84
+ )
85
+ input = self._values.decode(request.step_input, step.input_codec)
86
+ decision: Any = step.step.execute(context, input)
87
+ if isawaitable(decision):
88
+ decision = await decision
89
+ if not isinstance(decision, StepDecision):
90
+ raise TypeError("execute must return StepDecision")
91
+ return pb.InvokeExecuteMethodResponse(
92
+ step_decision=self._map_decision(flow, decision),
93
+ upsert_attributes=list(context.attribute_writes.values()),
94
+ record_events=context.events,
95
+ upsert_step_exe_locals=list(context.local_writes.values()),
96
+ publish_to_channel=context.publications,
97
+ )
98
+
99
+ async def invoke_rpc( # type: ignore[override]
100
+ self,
101
+ original: pb.InvokeWorkerRPCRequest,
102
+ ) -> pb.InvokeWorkerRPCResponse:
103
+ request = await self._hydrator.rpc_request(original)
104
+ flow = self._registry._flow_by_type(request.flow_type)
105
+ rpc = flow.rpc(request.rpc_name)
106
+ context = InvocationContext(
107
+ InvocationMethod.RPC,
108
+ flow,
109
+ request.context,
110
+ self._values,
111
+ request.attributes,
112
+ channel_infos=dict(request.channel_infos),
113
+ )
114
+ arguments: list[object] = [context]
115
+ if rpc.input_codec is not None:
116
+ arguments.append(self._values.decode(request.input, rpc.input_codec))
117
+ returned: Any = rpc.method(*arguments)
118
+ if isawaitable(returned):
119
+ returned = await returned
120
+ response = pb.InvokeWorkerRPCResponse(
121
+ upsert_attributes=list(context.attribute_writes.values()),
122
+ record_events=context.events,
123
+ publish_to_channel=context.publications,
124
+ )
125
+ if isinstance(returned, RPCResult):
126
+ if rpc.output_codec is None:
127
+ raise TypeError("RPCResult requires an output type")
128
+ response.output.CopyFrom(
129
+ self._values.encode(returned.output, rpc.output_codec)
130
+ )
131
+ if returned.next_steps:
132
+ response.step_decision.next_steps.extend(
133
+ self._map_movements(flow, returned.next_steps)
134
+ )
135
+ elif returned is None and rpc.output_codec is None:
136
+ response.output.CopyFrom(self._values.encode_dynamic(None))
137
+ else:
138
+ raise TypeError("RPC must return RPCResult or None")
139
+ return response