ledgence-client 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 (75) hide show
  1. ledgence_client-0.1.1/LICENSE +21 -0
  2. ledgence_client-0.1.1/PKG-INFO +506 -0
  3. ledgence_client-0.1.1/README.md +437 -0
  4. ledgence_client-0.1.1/pyproject.toml +41 -0
  5. ledgence_client-0.1.1/src/ledgence/client/__init__.py +52 -0
  6. ledgence_client-0.1.1/src/ledgence/client/_version.py +3 -0
  7. ledgence_client-0.1.1/src/ledgence/client/client.py +84 -0
  8. ledgence_client-0.1.1/src/ledgence/client/cloud_events.py +158 -0
  9. ledgence_client-0.1.1/src/ledgence/client/codec.py +199 -0
  10. ledgence_client-0.1.1/src/ledgence/client/completion_models.py +221 -0
  11. ledgence_client-0.1.1/src/ledgence/client/completions.py +164 -0
  12. ledgence_client-0.1.1/src/ledgence/client/discovery.py +91 -0
  13. ledgence_client-0.1.1/src/ledgence/client/errors.py +167 -0
  14. ledgence_client-0.1.1/src/ledgence/client/models.py +337 -0
  15. ledgence_client-0.1.1/src/ledgence/client/otel.py +116 -0
  16. ledgence_client-0.1.1/src/ledgence/client/py.typed +0 -0
  17. ledgence_client-0.1.1/src/ledgence/client/tasks.py +320 -0
  18. ledgence_client-0.1.1/src/ledgence/client/transport.py +235 -0
  19. ledgence_client-0.1.1/src/ledgence/client/workflow_models.py +198 -0
  20. ledgence_client-0.1.1/src/ledgence/client/workflows.py +246 -0
  21. ledgence_client-0.1.1/tests/json-values.json +121 -0
  22. ledgence_client-0.1.1/tests/support.py +49 -0
  23. ledgence_client-0.1.1/tests/test_client.py +575 -0
  24. ledgence_client-0.1.1/tests/test_codec.py +191 -0
  25. ledgence_client-0.1.1/tests/test_completions.py +272 -0
  26. ledgence_client-0.1.1/tests/test_discovery.py +268 -0
  27. ledgence_client-0.1.1/tests/test_otel.py +157 -0
  28. ledgence_client-0.1.1/tests/test_workflow_events.py +247 -0
  29. ledgence_client-0.1.1/tests/test_workflows.py +390 -0
  30. ledgence_client-0.1.1/tests/workflow-event-times.json +32 -0
  31. ledgence_client-0.1.1/third_party/NOTICE.md +29 -0
  32. ledgence_client-0.1.1/third_party/build-requirements.txt +3 -0
  33. ledgence_client-0.1.1/third_party/inventory.json +1631 -0
  34. ledgence_client-0.1.1/third_party/licenses/aiohappyeyeballs-2.7.1/source/LICENSE +279 -0
  35. ledgence_client-0.1.1/third_party/licenses/aiohappyeyeballs-2.7.1/wheel/LICENSE +279 -0
  36. ledgence_client-0.1.1/third_party/licenses/aiohttp-3.14.3/source/LICENSE.txt +201 -0
  37. ledgence_client-0.1.1/third_party/licenses/aiohttp-3.14.3/source/vendor/llhttp/LICENSE +22 -0
  38. ledgence_client-0.1.1/third_party/licenses/aiohttp-3.14.3/wheel/LICENSE.txt +201 -0
  39. ledgence_client-0.1.1/third_party/licenses/aiohttp-3.14.3/wheel/vendor/llhttp/LICENSE +22 -0
  40. ledgence_client-0.1.1/third_party/licenses/aiosignal-1.4.0/source/LICENSE +201 -0
  41. ledgence_client-0.1.1/third_party/licenses/aiosignal-1.4.0/wheel/LICENSE +201 -0
  42. ledgence_client-0.1.1/third_party/licenses/attrs-26.1.0/source/LICENSE +21 -0
  43. ledgence_client-0.1.1/third_party/licenses/attrs-26.1.0/source/docs/license.md +13 -0
  44. ledgence_client-0.1.1/third_party/licenses/attrs-26.1.0/wheel/LICENSE +21 -0
  45. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/source/LICENSE +29 -0
  46. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/source/flit_core/vendor/tomli-1.2.3.dist-info/LICENSE +21 -0
  47. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep517/LICENSE +1 -0
  48. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621/LICENSE +1 -0
  49. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621_license_files/LICENSE +1 -0
  50. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621_license_files/module/vendor/LICENSE_VENDOR +1 -0
  51. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/source/tests_core/samples/with_data_dir/LICENSE +1 -0
  52. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/wheel/LICENSE +29 -0
  53. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/wheel/flit_core/vendor/tomli-1.2.3.dist-info/LICENSE +21 -0
  54. ledgence_client-0.1.1/third_party/licenses/flit-core-3.12.0/wheel/vendor/tomli-1.2.3.dist-info/LICENSE +21 -0
  55. ledgence_client-0.1.1/third_party/licenses/frozenlist-1.8.0/source/LICENSE +201 -0
  56. ledgence_client-0.1.1/third_party/licenses/frozenlist-1.8.0/wheel/LICENSE +201 -0
  57. ledgence_client-0.1.1/third_party/licenses/idna-3.19/source/LICENSE.md +31 -0
  58. ledgence_client-0.1.1/third_party/licenses/idna-3.19/wheel/LICENSE.md +31 -0
  59. ledgence_client-0.1.1/third_party/licenses/multidict-6.8.0/source/LICENSE +201 -0
  60. ledgence_client-0.1.1/third_party/licenses/multidict-6.8.0/wheel/LICENSE +201 -0
  61. ledgence_client-0.1.1/third_party/licenses/opentelemetry-api-1.44.0/source/LICENSE +201 -0
  62. ledgence_client-0.1.1/third_party/licenses/opentelemetry-api-1.44.0/wheel/LICENSE +201 -0
  63. ledgence_client-0.1.1/third_party/licenses/propcache-0.5.2/source/LICENSE +202 -0
  64. ledgence_client-0.1.1/third_party/licenses/propcache-0.5.2/source/NOTICE +13 -0
  65. ledgence_client-0.1.1/third_party/licenses/propcache-0.5.2/wheel/LICENSE +202 -0
  66. ledgence_client-0.1.1/third_party/licenses/propcache-0.5.2/wheel/NOTICE +13 -0
  67. ledgence_client-0.1.1/third_party/licenses/typing-extensions-4.16.0/source/LICENSE +279 -0
  68. ledgence_client-0.1.1/third_party/licenses/typing-extensions-4.16.0/wheel/LICENSE +279 -0
  69. ledgence_client-0.1.1/third_party/licenses/yarl-1.24.5/source/LICENSE +202 -0
  70. ledgence_client-0.1.1/third_party/licenses/yarl-1.24.5/source/NOTICE +13 -0
  71. ledgence_client-0.1.1/third_party/licenses/yarl-1.24.5/wheel/LICENSE +202 -0
  72. ledgence_client-0.1.1/third_party/licenses/yarl-1.24.5/wheel/NOTICE +13 -0
  73. ledgence_client-0.1.1/third_party/otel-requirements.txt +4 -0
  74. ledgence_client-0.1.1/third_party/runtime-requirements.txt +12 -0
  75. ledgence_client-0.1.1/third_party/test-requirements.txt +3 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ledgence
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,506 @@
1
+ Metadata-Version: 2.4
2
+ Name: ledgence-client
3
+ Version: 0.1.1
4
+ Summary: An asynchronous Python client for Ledgence tasks, workflows, and completion notifications
5
+ Author-email: Ledgence <dev@ledgence.com>
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ License-File: third_party/NOTICE.md
11
+ License-File: third_party/licenses/aiohappyeyeballs-2.7.1/source/LICENSE
12
+ License-File: third_party/licenses/aiohappyeyeballs-2.7.1/wheel/LICENSE
13
+ License-File: third_party/licenses/aiohttp-3.14.3/source/LICENSE.txt
14
+ License-File: third_party/licenses/aiohttp-3.14.3/source/vendor/llhttp/LICENSE
15
+ License-File: third_party/licenses/aiohttp-3.14.3/wheel/LICENSE.txt
16
+ License-File: third_party/licenses/aiohttp-3.14.3/wheel/vendor/llhttp/LICENSE
17
+ License-File: third_party/licenses/aiosignal-1.4.0/source/LICENSE
18
+ License-File: third_party/licenses/aiosignal-1.4.0/wheel/LICENSE
19
+ License-File: third_party/licenses/attrs-26.1.0/source/LICENSE
20
+ License-File: third_party/licenses/attrs-26.1.0/source/docs/license.md
21
+ License-File: third_party/licenses/attrs-26.1.0/wheel/LICENSE
22
+ License-File: third_party/licenses/flit-core-3.12.0/source/LICENSE
23
+ License-File: third_party/licenses/flit-core-3.12.0/source/flit_core/vendor/tomli-1.2.3.dist-info/LICENSE
24
+ License-File: third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep517/LICENSE
25
+ License-File: third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621/LICENSE
26
+ License-File: third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621_license_files/LICENSE
27
+ License-File: third_party/licenses/flit-core-3.12.0/source/tests_core/samples/pep621_license_files/module/vendor/LICENSE_VENDOR
28
+ License-File: third_party/licenses/flit-core-3.12.0/source/tests_core/samples/with_data_dir/LICENSE
29
+ License-File: third_party/licenses/flit-core-3.12.0/wheel/LICENSE
30
+ License-File: third_party/licenses/flit-core-3.12.0/wheel/flit_core/vendor/tomli-1.2.3.dist-info/LICENSE
31
+ License-File: third_party/licenses/flit-core-3.12.0/wheel/vendor/tomli-1.2.3.dist-info/LICENSE
32
+ License-File: third_party/licenses/frozenlist-1.8.0/source/LICENSE
33
+ License-File: third_party/licenses/frozenlist-1.8.0/wheel/LICENSE
34
+ License-File: third_party/licenses/idna-3.19/source/LICENSE.md
35
+ License-File: third_party/licenses/idna-3.19/wheel/LICENSE.md
36
+ License-File: third_party/licenses/multidict-6.8.0/source/LICENSE
37
+ License-File: third_party/licenses/multidict-6.8.0/wheel/LICENSE
38
+ License-File: third_party/licenses/opentelemetry-api-1.44.0/source/LICENSE
39
+ License-File: third_party/licenses/opentelemetry-api-1.44.0/wheel/LICENSE
40
+ License-File: third_party/licenses/propcache-0.5.2/source/LICENSE
41
+ License-File: third_party/licenses/propcache-0.5.2/source/NOTICE
42
+ License-File: third_party/licenses/propcache-0.5.2/wheel/LICENSE
43
+ License-File: third_party/licenses/propcache-0.5.2/wheel/NOTICE
44
+ License-File: third_party/licenses/typing-extensions-4.16.0/source/LICENSE
45
+ License-File: third_party/licenses/typing-extensions-4.16.0/wheel/LICENSE
46
+ License-File: third_party/licenses/yarl-1.24.5/source/LICENSE
47
+ License-File: third_party/licenses/yarl-1.24.5/source/NOTICE
48
+ License-File: third_party/licenses/yarl-1.24.5/wheel/LICENSE
49
+ License-File: third_party/licenses/yarl-1.24.5/wheel/NOTICE
50
+ Requires-Dist: aiohappyeyeballs==2.7.1
51
+ Requires-Dist: aiohttp==3.14.3
52
+ Requires-Dist: aiosignal==1.4.0
53
+ Requires-Dist: attrs==26.1.0
54
+ Requires-Dist: frozenlist==1.8.0
55
+ Requires-Dist: idna==3.19
56
+ Requires-Dist: multidict==6.8.0
57
+ Requires-Dist: propcache==0.5.2
58
+ Requires-Dist: typing-extensions==4.16.0; python_version < "3.13"
59
+ Requires-Dist: yarl==1.24.5
60
+ Requires-Dist: opentelemetry-api==1.44.0 ; extra == "otel"
61
+ Requires-Dist: typing-extensions==4.16.0 ; extra == "otel"
62
+ Project-URL: Changelog, https://github.com/Ledgence/ledgence/releases
63
+ Project-URL: Documentation, https://github.com/Ledgence/ledgence/blob/main/sdk/python-client/README.md
64
+ Project-URL: Homepage, https://github.com/Ledgence/ledgence
65
+ Project-URL: Issues, https://github.com/Ledgence/ledgence/issues
66
+ Project-URL: Repository, https://github.com/Ledgence/ledgence
67
+ Provides-Extra: otel
68
+
69
+ # Ledgence Python client
70
+
71
+ An MIT-licensed, asynchronous Python client for submitting tasks and workflows,
72
+ observing execution, and registering durable completion notifications with a
73
+ self-hosted Ledgence service. The distribution is `ledgence-client`; its public
74
+ import is `ledgence.client`. Python 3.11–3.14 is tested on Linux x86_64 and macOS
75
+ arm64. Public APIs may evolve before version 1.0.
76
+
77
+ ## Installation
78
+
79
+ Install a published version from PyPI:
80
+
81
+ ```sh
82
+ python -m pip install ledgence-client
83
+ ```
84
+
85
+ For a locally qualified build, install its wheel:
86
+
87
+ ```sh
88
+ python -m pip install /path/to/ledgence_client-0.1.1-py3-none-any.whl
89
+ ```
90
+
91
+ The client connects to an existing Ledgence service. Follow the
92
+ [local deployment guide](https://github.com/Ledgence/ledgence/blob/main/docs/local-deployment.md)
93
+ to start a service and publish a program before running the example.
94
+
95
+ ## Submit a task
96
+
97
+ ```python
98
+ import asyncio
99
+ from ledgence.client import AsyncClient
100
+
101
+ async def main():
102
+ async with AsyncClient(
103
+ "http://localhost:8080", tenant="acme", namespace="billing"
104
+ ) as client:
105
+ task = await client.tasks.submit(
106
+ program="invoice-issuer", version="1.0.0", queue="billing",
107
+ data={"invoice_id": "INV-1042"},
108
+ idempotency_key="issue:INV-1042",
109
+ correlation_key="INV-1042",
110
+ )
111
+ print(task.id)
112
+ output = await task.result(timeout=60)
113
+ print(output)
114
+
115
+ asyncio.run(main())
116
+ ```
117
+
118
+ The caller owns its event loop. Keep one client open across calls and use it on
119
+ that loop. Programs receive the complete CloudEvent and execute in the Rust worker.
120
+ Protocol v3 programs can use synchronous or asynchronous Python handlers. The client neither uploads
121
+ packages nor imports handlers. It is separate from the dependency-free
122
+ `ledgence.worker` runtime helper and does not package that helper or CPython.
123
+ Both use the native `ledgence` namespace: neither distribution owns a root
124
+ `ledgence/__init__.py`, and the client keeps its typing marker in `ledgence/client/`.
125
+ Programs use `from ledgence.worker.workflow import workflow_context`; the old
126
+ `ledgence_worker` imports must be updated before running on current workers.
127
+
128
+ ## Task references and observations
129
+
130
+ Save the task ID, tenant, namespace and server location to reconnect. Constructing
131
+ `client.tasks.handle(task_id)` makes no network call. Handles retain their scoped
132
+ client; use a new client's handle after that client's lifetime ends.
133
+
134
+ | Operation | Result |
135
+ | --- | --- |
136
+ | `await task.status()` | Compact `TaskStatus` with scheduling and attempt metadata |
137
+ | `await task.outcome()` | `TaskResult`; `.outcome` is `None` exactly while queued/active |
138
+ | `await task.wait(timeout=60)` | Full terminal `TaskResult`, including failures/cancellation as values |
139
+ | `await task.result(timeout=60)` | User JSON output, or `TaskFailed` / `TaskCancelled` |
140
+ | `await task.cancel()` | Actual acknowledged `TaskState`; it can still be `active` |
141
+
142
+ States and outcome kinds compare naturally to strings. Successful JSON `null`
143
+ returns Python `None`; it is distinct from a pending `TaskResult.outcome`.
144
+ Failures retain structured application, execution or lost-attempt details. They
145
+ never instantiate arbitrary remote exception classes. `TaskFailed.result` and
146
+ `TaskCancelled.result` retain the full observation.
147
+
148
+ Logical success does not imply exactly-once external effects or confirmed
149
+ physical cleanup. Check `result.outcome.quiescence` when cleanup evidence matters;
150
+ `result()` returns a succeeded task's output even when quiescence is unconfirmed.
151
+ Cancellation outcomes have no deciding attempt or output. The status's
152
+ `latest_attempt_id` is only a diagnostic reference to earlier work.
153
+
154
+ ## Workflow references and observations
155
+
156
+ A workflow starts a published controller program that returns explicit checkpoint
157
+ decisions. Use the same submission arguments with `client.workflows`:
158
+
159
+ ```python
160
+ workflow = await client.workflows.submit(
161
+ program="checkpoint-workflow", version="1.0.0", queue="billing",
162
+ data={"invoice_id": "INV-1042"}, idempotency_key="workflow:INV-1042",
163
+ correlation_key="INV-1042",
164
+ )
165
+ print(workflow.id)
166
+ output = await workflow.result(timeout=60)
167
+ ```
168
+
169
+ Save the workflow ID and reconnect with `client.workflows.handle(workflow_id)`.
170
+ `status()` returns `WorkflowStatus`; `outcome()` returns `WorkflowResult` with
171
+ `.outcome=None` while work is pending. `wait()` returns the terminal result;
172
+ `result()` returns successful JSON or raises `WorkflowFailed` / `WorkflowCancelled`.
173
+ `cancel()` returns the acknowledged `WorkflowStatus`, which can be `cancelling`
174
+ while active work drains. Failure and cancellation remain nonterminal in the
175
+ `failing` and `cancelling` states.
176
+
177
+ Each owned subworkflow has its own workflow ID. Reconnect to that ID with the same
178
+ `client.workflows.handle(...)` API to inspect its status/result, send an event, or
179
+ cancel it independently. `WorkflowStatus.parent_workflow_id` is null for roots;
180
+ `root_workflow_id` is the root's own ID for a root and the ancestor root ID for a
181
+ nested workflow. These fields are immutable across observations. A terminal child
182
+ controller task does not imply that its workflow is complete.
183
+
184
+ The public `submit()` endpoint starts root workflows. Controllers create owned
185
+ children using the worker helper's `ctx.workflow(...)`. Parent cancellation and
186
+ failure drain the owned tree before reaching a terminal status. See
187
+ [`docs/subworkflows.md`](https://github.com/Ledgence/ledgence/blob/main/docs/subworkflows.md) for composition and result
188
+ semantics.
189
+
190
+ `WorkflowWaitTimeout` is also a `WaitTimeout`. It retains `.workflow`, `.last_status`
191
+ and `.last_error`; observation timeout never cancels or resubmits the workflow.
192
+ Observe the saved handle again to continue waiting. For durable external notification
193
+ without keeping the client running, register a completion subscription below.
194
+
195
+ For an uncertain submission, `SubmissionUncertain.submission` retains a frozen
196
+ `WorkflowSubmission` from `client.workflows.prepare(...)`. Resend that command
197
+ explicitly to the workflow endpoint with the same idempotency key. Task and workflow
198
+ commands are distinct types to prevent accidentally replaying one as the other.
199
+ `WorkflowCancellationUncertain` similarly retains the workflow for reconciliation.
200
+
201
+ Controller authoring and durability semantics are described in
202
+ [`docs/workflows.md`](https://github.com/Ledgence/ledgence/blob/main/docs/workflows.md).
203
+
204
+ ## Durable completion subscriptions
205
+
206
+ Register notification delivery to an operator-configured destination alias:
207
+
208
+ ```python
209
+ from ledgence.client import CompletionSubscriptionUncertain
210
+
211
+ command = task.prepare_subscribe(
212
+ destination="billing-results", idempotency_key="invoice-completion",
213
+ )
214
+ try:
215
+ subscription = await task.subscribe(command)
216
+ except CompletionSubscriptionUncertain as uncertain:
217
+ # Persist this command and reconcile under your application's retry policy.
218
+ saved_command = uncertain.command.to_dict()
219
+ raise
220
+
221
+ print(subscription.id)
222
+ print((await subscription.status()).state)
223
+ ```
224
+
225
+ `await task.subscribe(destination="billing-results", idempotency_key="invoice-completion")`
226
+ is the convenience form; `workflow.prepare_subscribe()` and `workflow.subscribe()`
227
+ use the same contract. The workflow form observes the complete workflow, including
228
+ owned-child draining, rather than a controller activation. `client.completions.prepare()`
229
+ accepts an explicit `CompletionTarget("task", task_id)` or
230
+ `CompletionTarget("workflow", workflow_id)`. `client.completions.subscribe(command)`
231
+ reconciles either prepared command.
232
+
233
+ Registration is one HTTP operation, not a background listener in Python. After its
234
+ durable acceptance the caller can disconnect. Registration also works after the
235
+ execution finishes. The guarantee begins when registration commits: submitting
236
+ work and registering its subscription are separate operations. Save the task or
237
+ workflow identity and retry registration if the client stops between these calls.
238
+ The idempotency key is scoped to the execution; changing its destination conflicts.
239
+
240
+ Save the subscription ID and reconnect with
241
+ `client.completions.handle(subscription_id)`. `status()` returns a bounded
242
+ `CompletionSubscription` with its immutable command, delivery state, generation,
243
+ attempt counters, timestamps, last failure, and nullable completion CloudEvent.
244
+ States are `waiting`, `pending`, `delivering`, `retrying`, `delivered`, and `exhausted`.
245
+ `waiting` means the execution is still nonterminal; `delivered` confirms receiver
246
+ acceptance, not completion of business effects at the receiver.
247
+
248
+ For explicit redelivery after exhaustion:
249
+
250
+ ```python
251
+ from ledgence.client import CompletionRetryUncertain
252
+
253
+ status = await subscription.status()
254
+ if status.state == "exhausted":
255
+ command = subscription.prepare_retry(expected_generation=status.generation)
256
+ try:
257
+ status = await subscription.retry(command)
258
+ except CompletionRetryUncertain as uncertain:
259
+ saved_command = uncertain.command.to_dict()
260
+ raise
261
+ ```
262
+
263
+ `await subscription.retry(expected_generation=status.generation)` is the convenience
264
+ form. Reuse the same prepared command when acceptance is uncertain. Repeating an
265
+ accepted retry observes the current generation without rearming delivery again;
266
+ it never reruns the task or workflow. Like other mutations, the SDK does not retry
267
+ subscription or redelivery requests automatically. Caller cancellation remains
268
+ `asyncio.CancelledError`; prepare and persist commands before awaiting if they must
269
+ survive that cancellation.
270
+
271
+ The initial notification is a reference-only CloudEvent. Execution IDs, terminal
272
+ state, business correlation, result reference, and trace context live in its
273
+ envelope; it has no `data` field and does not copy user output. Fetch the result
274
+ through the existing scoped task/workflow API. Receivers must deduplicate using
275
+ `(source, id)` and durably accept a notification before acknowledging it. Notification
276
+ retries and explicit redelivery preserve the event identity. See
277
+ [completion notifications](https://github.com/Ledgence/ledgence/blob/main/docs/completion-notifications.md) for delivery
278
+ semantics, destination configuration, limits, and receiver behavior.
279
+
280
+ ## Finding tasks
281
+
282
+ ```python
283
+ page = await client.tasks.list(
284
+ state="failed", correlation_key="INV-1042", limit=50,
285
+ )
286
+ for task in page.items:
287
+ print(task.task_id, task.state, task.latest_attempt_id)
288
+ if page.next_cursor is not None:
289
+ page = await client.tasks.list(
290
+ state="failed", correlation_key="INV-1042", limit=50,
291
+ cursor=page.next_cursor,
292
+ )
293
+ ```
294
+
295
+ `list()` returns one immutable `TaskPage` containing a tuple of compact
296
+ `TaskStatus` observations and a nullable `next_cursor`. Each call has the client's
297
+ existing request deadline. It does not fetch results or automatically traverse
298
+ more pages. Filters always apply inside the client's tenant and namespace.
299
+
300
+ Optional `state` accepts one state string or `TaskState`; `queue` and
301
+ `correlation_key` match exactly. An omitted correlation filter matches any key;
302
+ `correlation_key=""` matches only an explicitly empty key. `submitted_from` is
303
+ inclusive and `submitted_until` exclusive, in Unix epoch milliseconds from zero
304
+ through `253402300799999`. If both are present, `submitted_from` must be smaller.
305
+ `limit` defaults to 50 and must be an integer from 1 through 100.
306
+
307
+ Tasks are ordered by immutable `(submitted_at, task_id)`, descending. Treat the
308
+ cursor as opaque, repeat the same filters and use it with the same scope; changing
309
+ the page limit is allowed. A null cursor ends this traversal. Each page reads
310
+ committed state when queried. Multiple pages are not a frozen snapshot: tasks may
311
+ change state or become visible between requests, and concurrent changes can make
312
+ matching tasks enter or leave the remaining traversal. Start again without a
313
+ cursor to refresh. Use a task handle's status or result for subsequent observation.
314
+
315
+ ## External workflow events
316
+
317
+ Send the complete original CloudEvent to a one-shot wait key:
318
+
319
+ ```python
320
+ from ledgence.client import WorkflowEventUncertain
321
+
322
+ workflow = client.workflows.handle(saved_workflow_id)
323
+ command = workflow.prepare_event("approval:1", event={
324
+ "specversion": "1.0", "id": "approval-1042", "source": "/billing/approvals",
325
+ "type": "invoice.approved", "datacontenttype": "application/json",
326
+ "data": {"invoice_id": "INV-1042", "approved": True},
327
+ })
328
+ try:
329
+ receipt = await workflow.send_event(command)
330
+ except WorkflowEventUncertain as error:
331
+ # Application policy decides when to resend these unchanged bytes.
332
+ saved_command = error.command.to_dict()
333
+ raise
334
+ ```
335
+
336
+ `await workflow.send_event(key="approval:1", event=original_event)` is the
337
+ convenience form. Preparation freezes the endpoint, scope, workflow, key and full
338
+ event; it generates no event ID and changes no CloudEvent fields. Store the
339
+ prepared command before an await when caller cancellation may require later
340
+ reconciliation. `to_dict()` returns an independent JSON copy. After a caller
341
+ restart, reconnect to the same endpoint/scope/workflow and call
342
+ `prepare_event(saved_command["key"], event=saved_command["event"])`.
343
+
344
+ `WorkflowEventReceipt` contains `scope`, `workflow_id`, `key`, `event_id`,
345
+ `event_source`, `accepted_at` (Unix milliseconds), and `already_accepted`.
346
+ Acceptance means durable storage, not that the workflow already consumed the
347
+ event. An event may arrive before the controller registers its wait. Source and
348
+ ID identify an event within the workflow run, and each wait key accepts one event.
349
+ Resending an identical command returns its receipt, including after workflow
350
+ completion. Changed bindings raise `Conflict`; closed/late events produce
351
+ `ServiceError` with `code="obsolete_operation"`.
352
+
353
+ Event POSTs never automatically retry. An uncertain response raises
354
+ `WorkflowEventUncertain` with `.command`, `.cause`, and an optional `.request_id`.
355
+ Explicitly resend `.command` to reconcile. `asyncio.CancelledError` still
356
+ propagates; cancellation does not prove that acceptance failed. Known
357
+ pre-dispatch deadline expiration remains `RequestTimeout(dispatched=False)`.
358
+
359
+ Events use the common CloudEvents JSON profile: version1.0; nonempty `id`,
360
+ `source`, and `type`; `datacontenttype="application/json"`; and a required `data`
361
+ field (which may be null). Optional time/subject/schema/tracing and scalar context
362
+ extensions are validated. Execution IDs are not required; forwarded context
363
+ fields remain intact. Complete encoded events are capped at 64 KiB, commands at
364
+ 70 KiB, and application data at depth64. Preparation uses the strict Python-encoded size, so a float-format boundary may
365
+ be rejected conservatively even if the Rust encoding would fit. Wait keys are one-shot for the whole run;
366
+ use new iteration keys when the controller waits again.
367
+
368
+ External event IDs are limited to 128 UTF-8 bytes and sources to 2,048 UTF-8
369
+ bytes, in addition to the complete event budget. Sources must be valid URI
370
+ references; encode non-ASCII URI characters with percent escapes.
371
+
372
+ ## Submission uncertainty
373
+
374
+ An explicit idempotency key is required. Optional `RetryPolicy`,
375
+ `attempt_timeout_ms`, `correlation_key` and `origin_trace` map to the existing
376
+ server fields; omitted scheduling settings retain server defaults.
377
+
378
+ ```python
379
+ from ledgence.client import SubmissionUncertain
380
+
381
+ submission = client.tasks.prepare(
382
+ program="invoice-issuer", version="1.0.0", queue="billing",
383
+ data={"invoice_id": "INV-1042"}, idempotency_key="issue:INV-1042",
384
+ )
385
+ try:
386
+ task = await client.tasks.submit(submission)
387
+ except SubmissionUncertain as error:
388
+ # The application chooses when to retry this same immutable command.
389
+ saved_command = error.submission.to_dict()
390
+ raise
391
+ ```
392
+
393
+ `prepare()` is synchronous and local. It freezes the endpoint, scope, command and
394
+ origin context (including absence) before transmission; later changes to the
395
+ caller's data cannot change it. `to_dict()` returns an independent copy suitable
396
+ for application-owned persistence. Reconstruct with the same key and semantic
397
+ input after a caller restart. Sending a prepared submission through a different
398
+ endpoint or scope is rejected locally.
399
+
400
+ POST submission and cancellation have no automatic retries. A lost, malformed or
401
+ unavailable response—including a valid server `503 unavailable`—may follow a
402
+ commit. `SubmissionUncertain` retains the frozen command; `CancellationUncertain`
403
+ retains `.task`. Both expose `.cause` and any server `.request_id`. Definitive
404
+ `NotFound`, `Conflict` and `ServiceError` reject this exchange; they cannot prove
405
+ that another concurrent exchange with the same key never committed.
406
+
407
+ Cancelling a Python await propagates `asyncio.CancelledError` and never sends
408
+ remote cancellation. If submission was in flight, use the prepared command or
409
+ original stable key/input to reconcile; cancellation does not prove rejection.
410
+ Closing the client likewise does not cancel remote tasks.
411
+
412
+ ## Deadlines and ownership
413
+
414
+ `request_timeout` defaults to 30 seconds and must be positive, finite and at most
415
+ 30. `wait`/`result` have a separate positive finite observation timeout, default
416
+ 60; there is no unbounded or zero mode. They poll compact status at most once per
417
+ second after each observation, then fetch the result within the same observation
418
+ budget. Transient read failures are retried during that budget. Protocol errors
419
+ and definitive rejections are immediate. A request deadline includes admission,
420
+ network/body transfer, decoding and validation. Convenience submission includes
421
+ local preparation too; an already prepared command was encoded before its later
422
+ exchange budget begins.
423
+
424
+ `RequestTimeout.dispatched` says whether network dispatch began. Known
425
+ pre-dispatch expiration is not mutation uncertainty. `WaitTimeout` contains
426
+ `.task`, optional `.last_status`, and optional `.last_error`; it never means task
427
+ failure, absence or cancellation. The SDK checks deadlines before each logical
428
+ exchange and after decoding, rejecting late success.
429
+
430
+ The client admits at most eight active HTTP exchanges and two codec jobs.
431
+ Caller-created concurrent requests wait for admission within their own deadlines. Waiting tasks sleep outside network admission. A timed-out codec
432
+ waiter does not free its job's slot while the work is still running. Client close
433
+ cancels owned exchanges and joins remaining codec work. If the close await itself
434
+ is cancelled, the owned close continues; call `await client.close()` again to join
435
+ it. CPU validation and Python's GIL are cooperative, so this is not a hard
436
+ real-time deadline or a promise to forcibly terminate native calls.
437
+
438
+ The explicitly selected threaded resolver uses the host's DNS behavior. One
439
+ connector coalesces same-host resolution while cancelled waiters detach; an
440
+ already running OS DNS call may continue, and event-loop/default-executor
441
+ shutdown can wait for it. The selected aiohttp release may transparently retry a
442
+ GET connection failure once under the same timer. POST is excluded. This SDK
443
+ does not alter private retry flags or pretend every GET is one wire request.
444
+
445
+ TLS uses the host Python SSL context and trust roots. Redirects and compressed
446
+ responses are rejected; system proxy configuration is not implicitly adopted.
447
+ No vendor service or account is required. Error responses are capped at 64 KiB,
448
+ status responses at 16 KiB, task pages at 2 MiB, and other responses at the
449
+ existing 16 MiB bound.
450
+ Client I/O admission does not alter worker execution concurrency.
451
+
452
+ ## JSON and optional tracing
453
+
454
+ User data stays user-owned JSON. The client preserves signed i64/unsigned u64
455
+ integers, finite binary64, integer/float distinction, negative floating zero,
456
+ Unicode scalar strings and escaped U+0000. Objects require string keys; tuples
457
+ normalize to arrays. Data has the existing 1 MiB compact JSON/64-container-depth
458
+ limit. Results retain current server/report limits. Oversized integers, nonfinite
459
+ numbers, Decimal/custom objects, cycles, lone surrogates and duplicate response
460
+ keys are rejected. Encode exact decimal quantities or larger identifiers as
461
+ strings. No Pydantic coercion, pickle or arbitrary remote Python objects are used.
462
+
463
+ ```python
464
+ from ledgence.client.otel import enable_context
465
+
466
+ enable_context() # Requires the optional opentelemetry-api dependency.
467
+ ```
468
+
469
+ The application owns any OTel provider/exporter. Context capture and HTTP client
470
+ spans are explicitly enabled; the base client imports no OTel dependency.
471
+ Preparation snapshots the application's active valid context once; an explicit
472
+ `TraceContext` wins and explicit `origin_trace=None` freezes absence. Replaying a
473
+ prepared submission does not replace that durable context, while later transport
474
+ spans use the currently active context. No input, output or idempotency key is
475
+ recorded as a span attribute. Task/run/event/attempt IDs and per-exchange request
476
+ IDs remain distinct from tracing IDs.
477
+
478
+ ## Local verification
479
+
480
+ The repository's [package verification gate](https://github.com/Ledgence/ledgence/blob/main/tools/check-python-client.py)
481
+ builds a source distribution, rebuilds its wheel, and tests the installed client
482
+ outside the checkout using the reviewed dependency inventory. Run it from the
483
+ repository root to retain the verified distributions:
484
+
485
+ ```sh
486
+ python tools/check-python-client.py --dist-dir /path/to/new-dist-directory
487
+ ```
488
+
489
+ See its `--help` for offline wheelhouse and retained-environment options. Source
490
+ tests also run from the repository root with:
491
+
492
+ ```sh
493
+ PYTHONPATH=sdk/python-client/src python -m unittest discover -s sdk/python-client/tests -v
494
+ ```
495
+
496
+ The selected interpreter must already have the pinned dependencies. Optional
497
+ trace tests run when the reviewed OTel API is installed; no exporter is used.
498
+ The source distribution includes its tests and JSON fixture corpus. After
499
+ installing the package and dependencies, run `python -m unittest discover -s tests -v`
500
+ from its extracted directory. `LEDGENCE_JSON_FIXTURES` can explicitly select an
501
+ alternative JSON corpus; the repository gate checks the bundled corpus against
502
+ the shared Rust/Python fixtures. Dependency versions, wheel/source hashes,
503
+ licenses and notices are retained under `third_party`; normal wheel installation
504
+ pins the reviewed runtime closure. The gate verifies those pins and distributed
505
+ legal files. No dependencies are downloaded during program execution.
506
+