lodol 0.2.0__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 (38) hide show
  1. lodol-0.2.0/LICENSE +185 -0
  2. lodol-0.2.0/MANIFEST.in +4 -0
  3. lodol-0.2.0/PKG-INFO +367 -0
  4. lodol-0.2.0/README.md +325 -0
  5. lodol-0.2.0/pyproject.toml +85 -0
  6. lodol-0.2.0/setup.cfg +4 -0
  7. lodol-0.2.0/src/lodol/__init__.py +115 -0
  8. lodol-0.2.0/src/lodol/_logs.py +26 -0
  9. lodol-0.2.0/src/lodol/_transport.py +806 -0
  10. lodol-0.2.0/src/lodol/client.py +163 -0
  11. lodol-0.2.0/src/lodol/constants.py +51 -0
  12. lodol-0.2.0/src/lodol/exceptions.py +219 -0
  13. lodol-0.2.0/src/lodol/models.py +542 -0
  14. lodol-0.2.0/src/lodol/pagination.py +114 -0
  15. lodol-0.2.0/src/lodol/py.typed +0 -0
  16. lodol-0.2.0/src/lodol/resources/__init__.py +16 -0
  17. lodol-0.2.0/src/lodol/resources/_common.py +19 -0
  18. lodol-0.2.0/src/lodol/resources/approvals.py +380 -0
  19. lodol-0.2.0/src/lodol/resources/runs.py +853 -0
  20. lodol-0.2.0/src/lodol/resources/workflows.py +533 -0
  21. lodol-0.2.0/src/lodol/version.py +3 -0
  22. lodol-0.2.0/src/lodol.egg-info/PKG-INFO +367 -0
  23. lodol-0.2.0/src/lodol.egg-info/SOURCES.txt +36 -0
  24. lodol-0.2.0/src/lodol.egg-info/dependency_links.txt +1 -0
  25. lodol-0.2.0/src/lodol.egg-info/requires.txt +15 -0
  26. lodol-0.2.0/src/lodol.egg-info/top_level.txt +1 -0
  27. lodol-0.2.0/tests/__init__.py +0 -0
  28. lodol-0.2.0/tests/conftest.py +181 -0
  29. lodol-0.2.0/tests/standin.py +1218 -0
  30. lodol-0.2.0/tests/test_approvals.py +532 -0
  31. lodol-0.2.0/tests/test_async.py +518 -0
  32. lodol-0.2.0/tests/test_client.py +199 -0
  33. lodol-0.2.0/tests/test_live_server.py +543 -0
  34. lodol-0.2.0/tests/test_models.py +378 -0
  35. lodol-0.2.0/tests/test_readme.py +49 -0
  36. lodol-0.2.0/tests/test_retries.py +632 -0
  37. lodol-0.2.0/tests/test_runs.py +1162 -0
  38. lodol-0.2.0/tests/test_workflows.py +679 -0
lodol-0.2.0/LICENSE ADDED
@@ -0,0 +1,185 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction, and
10
+ distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by the
13
+ copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all other
16
+ entities that control, are controlled by, or are under common control with
17
+ that entity. For the purposes of this definition, "control" means (i) the
18
+ power, direct or indirect, to cause the direction or management of such
19
+ entity, whether by contract or otherwise, or (ii) ownership of fifty percent
20
+ (50%) or more of the outstanding shares, or (iii) beneficial ownership of such
21
+ entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity exercising
24
+ permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation source, and
28
+ configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical transformation or
31
+ translation of a Source form, including but not limited to compiled object
32
+ code, generated documentation, and conversions to other media types.
33
+
34
+ "Work" shall mean the work of authorship, whether in Source or Object form,
35
+ made available under the License, as indicated by a copyright notice that is
36
+ included in or attached to the work (an example is provided in the Appendix
37
+ below).
38
+
39
+ "Derivative Works" shall mean any work, whether in Source or Object form, that
40
+ is based on (or derived from) the Work and for which the editorial revisions,
41
+ annotations, elaborations, or other modifications represent, as a whole, an
42
+ original work of authorship. For the purposes of this License, Derivative Works
43
+ shall not include works that remain separable from, or merely link (or bind by
44
+ name) to the interfaces of, the Work and Derivative Works thereof.
45
+
46
+ "Contribution" shall mean any work of authorship, including the original
47
+ version of the Work and any modifications or additions to that Work or
48
+ Derivative Works thereof, that is intentionally submitted to Licensor for
49
+ inclusion in the Work by the copyright owner or by an individual or Legal
50
+ Entity authorized to submit on behalf of the copyright owner. For the purposes
51
+ of this definition, "submitted" means any form of electronic, verbal, or
52
+ written communication sent to the Licensor or its representatives, including
53
+ but not limited to communication on electronic mailing lists, source code
54
+ control systems, and issue tracking systems that are managed by, or on behalf
55
+ of, the Licensor for the purpose of discussing and improving the Work, but
56
+ excluding communication that is conspicuously marked or otherwise designated
57
+ in writing by the copyright owner as "Not a Contribution."
58
+
59
+ "Contributor" shall mean Licensor and any individual or Legal Entity on behalf
60
+ of whom a Contribution has been received by Licensor and subsequently
61
+ incorporated within the Work.
62
+
63
+ 2. Grant of Copyright License. Subject to the terms and conditions of this
64
+ License, each Contributor hereby grants to You a perpetual, worldwide,
65
+ non-exclusive, no-charge, royalty-free, irrevocable copyright license to
66
+ reproduce, prepare Derivative Works of, publicly display, publicly perform,
67
+ sublicense, and distribute the Work and such Derivative Works in Source or
68
+ Object form.
69
+
70
+ 3. Grant of Patent License. Subject to the terms and conditions of this
71
+ License, each Contributor hereby grants to You a perpetual, worldwide,
72
+ non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this
73
+ section) patent license to make, have made, use, offer to sell, sell, import,
74
+ and otherwise transfer the Work, where such license applies only to those
75
+ patent claims licensable by such Contributor that are necessarily infringed by
76
+ their Contribution(s) alone or by combination of their Contribution(s) with the
77
+ Work to which such Contribution(s) was submitted. If You institute patent
78
+ litigation against any entity (including a cross-claim or counterclaim in a
79
+ lawsuit) alleging that the Work or a Contribution incorporated within the Work
80
+ constitutes direct or contributory patent infringement, then any patent
81
+ licenses granted to You under this License for that Work shall terminate as of
82
+ the date such litigation is filed.
83
+
84
+ 4. Redistribution. You may reproduce and distribute copies of the Work or
85
+ Derivative Works thereof in any medium, with or without modifications, and in
86
+ Source or Object form, provided that You meet the following conditions:
87
+
88
+ (a) You must give any other recipients of the Work or Derivative Works a copy
89
+ of this License; and
90
+
91
+ (b) You must cause any modified files to carry prominent notices stating that
92
+ You changed the files; and
93
+
94
+ (c) You must retain, in the Source form of any Derivative Works that You
95
+ distribute, all copyright, patent, trademark, and attribution notices from the
96
+ Source form of the Work, excluding those notices that do not pertain to any
97
+ part of the Derivative Works; and
98
+
99
+ (d) If the Work includes a "NOTICE" text file as part of its distribution, then
100
+ any Derivative Works that You distribute must include a readable copy of the
101
+ attribution notices contained within such NOTICE file, excluding those notices
102
+ that do not pertain to any part of the Derivative Works, in at least one of the
103
+ following places: within a NOTICE text file distributed as part of the
104
+ Derivative Works; within the Source form or documentation, if provided along
105
+ with the Derivative Works; or, within a display generated by the Derivative
106
+ Works, if and wherever such third-party notices normally appear. The contents
107
+ of the NOTICE file are for informational purposes only and do not modify the
108
+ License. You may add Your own attribution notices within Derivative Works that
109
+ You distribute, alongside or as an addendum to the NOTICE text from the Work,
110
+ provided that such additional attribution notices cannot be construed as
111
+ modifying the License.
112
+
113
+ You may add Your own copyright statement to Your modifications and may provide
114
+ additional or different license terms and conditions for use, reproduction, or
115
+ distribution of Your modifications, or for any such Derivative Works as a
116
+ whole, provided Your use, reproduction, and distribution of the Work otherwise
117
+ complies with the conditions stated in this License.
118
+
119
+ 5. Submission of Contributions. Unless You explicitly state otherwise, any
120
+ Contribution intentionally submitted for inclusion in the Work by You to the
121
+ Licensor shall be under the terms and conditions of this License, without any
122
+ additional terms or conditions. Notwithstanding the above, nothing herein shall
123
+ supersede or modify the terms of any separate license agreement you may have
124
+ executed with Licensor regarding such Contributions.
125
+
126
+ 6. Trademarks. This License does not grant permission to use the trade names,
127
+ trademarks, service marks, or product names of the Licensor, except as required
128
+ for reasonable and customary use in describing the origin of the Work and
129
+ reproducing the content of the NOTICE file.
130
+
131
+ 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in
132
+ writing, Licensor provides the Work (and each Contributor provides its
133
+ Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
134
+ KIND, either express or implied, including, without limitation, any warranties
135
+ or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
136
+ PARTICULAR PURPOSE. You are solely responsible for determining the
137
+ appropriateness of using or redistributing the Work and assume any risks
138
+ associated with Your exercise of permissions under this License.
139
+
140
+ 8. Limitation of Liability. In no event and under no legal theory, whether in
141
+ tort (including negligence), contract, or otherwise, unless required by
142
+ applicable law (such as deliberate and grossly negligent acts) or agreed to in
143
+ writing, shall any Contributor be liable to You for damages, including any
144
+ direct, indirect, special, incidental, or consequential damages of any character
145
+ arising as a result of this License or out of the use or inability to use the
146
+ Work (including but not limited to damages for loss of goodwill, work stoppage,
147
+ computer failure or malfunction, or any and all other commercial damages or
148
+ losses), even if such Contributor has been advised of the possibility of such
149
+ damages.
150
+
151
+ 9. Accepting Warranty or Additional Liability. While redistributing the Work or
152
+ Derivative Works thereof, You may choose to offer, and charge a fee for,
153
+ acceptance of support, warranty, indemnity, or other liability obligations
154
+ and/or rights consistent with this License. However, in accepting such
155
+ obligations, You may act only on Your own behalf and on Your sole
156
+ responsibility, not on behalf of any other Contributor, and only if You agree
157
+ to indemnify, defend, and hold each Contributor harmless for any liability
158
+ incurred by, or claims asserted against, such Contributor by reason of your
159
+ accepting any such warranty or additional liability.
160
+
161
+ END OF TERMS AND CONDITIONS
162
+
163
+ APPENDIX: How to apply the Apache License to your work.
164
+
165
+ To apply the Apache License to your work, attach the following boilerplate
166
+ notice, with the fields enclosed by brackets "[]" replaced with your own
167
+ identifying information. (Don't include the brackets!) The text should be
168
+ enclosed in the appropriate comment syntax for the file format. We also
169
+ recommend that a file or class name and description of purpose be included on
170
+ the same "printed page" as the copyright notice for easier identification
171
+ within third-party archives.
172
+
173
+ Copyright 2026 Lodol
174
+
175
+ Licensed under the Apache License, Version 2.0 (the "License");
176
+ you may not use this file except in compliance with the License.
177
+ You may obtain a copy of the License at
178
+
179
+ http://www.apache.org/licenses/LICENSE-2.0
180
+
181
+ Unless required by applicable law or agreed to in writing, software
182
+ distributed under the License is distributed on an "AS IS" BASIS,
183
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
184
+ See the License for the specific language governing permissions and
185
+ limitations under the License.
@@ -0,0 +1,4 @@
1
+ # The tests ship with the source, whole, so they can be run from it.
2
+ graft tests
3
+ global-exclude __pycache__
4
+ global-exclude *.py[cod]
lodol-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,367 @@
1
+ Metadata-Version: 2.4
2
+ Name: lodol
3
+ Version: 0.2.0
4
+ Summary: Run your Lodol workflows from Python: start runs, wait for them, and answer approvals
5
+ Author: Lodol
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://lodol.com
8
+ Project-URL: Documentation, https://docs.lodol.com
9
+ Project-URL: Source, https://github.com/LodolAI/lodol-python
10
+ Project-URL: Issues, https://github.com/LodolAI/lodol-python/issues
11
+ Project-URL: Changelog, https://github.com/LodolAI/lodol-python/blob/main/CHANGELOG.md
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.15
22
+ Classifier: Framework :: AsyncIO
23
+ Classifier: Framework :: Trio
24
+ Classifier: Operating System :: OS Independent
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: httpx2<3,>=2.14.0
30
+ Requires-Dist: anyio<5,>=4.15.1
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest>=8; extra == "test"
33
+ Requires-Dist: pytest-cov>=7.1.0; extra == "test"
34
+ Requires-Dist: trio>=0.34.0; extra == "test"
35
+ Provides-Extra: lint
36
+ Requires-Dist: black==26.10.1; extra == "lint"
37
+ Requires-Dist: mypy==2.4.0; extra == "lint"
38
+ Requires-Dist: pylint==4.1.3; extra == "lint"
39
+ Provides-Extra: dev
40
+ Requires-Dist: lodol[lint,test]; extra == "dev"
41
+ Dynamic: license-file
42
+
43
+ # Lodol for Python
44
+
45
+ Run your Lodol workflows from Python: start runs, wait for them, follow their history, and answer the approvals they ask for.
46
+
47
+ ```bash
48
+ pip install lodol
49
+ ```
50
+
51
+ Python 3.10 or later.
52
+
53
+ ## Quickstart
54
+
55
+ Create an API key in Lodol under **Settings > API Keys**, then:
56
+
57
+ ```bash
58
+ export LODOL_API_KEY="sk_live_..."
59
+ ```
60
+
61
+ ```python
62
+ from lodol import Lodol
63
+
64
+ lodol = Lodol()
65
+
66
+ run = lodol.workflows.run("665f...", inputs={"client": "Acme"}, wait=True)
67
+ print(run.outputs) # {"total": 1250}
68
+ ```
69
+
70
+ That starts the workflow, waits for it to finish, and gives you what it handed back: `run.outputs` holds each output the workflow declares, by name. If the run fails or is stopped, it raises `RunFailedError` instead, so a script never carries on as if it worked.
71
+
72
+ Make one `Lodol()` and reuse it. It keeps connections open between calls and can be shared between threads.
73
+
74
+ ## Run a workflow
75
+
76
+ ```python
77
+ workflow = lodol.workflows.get("665f...")
78
+
79
+ for workflow_input in workflow.inputs:
80
+ print(workflow_input.name, workflow_input.type, workflow_input.required)
81
+ for output in workflow.outputs:
82
+ print(output.name, output.type) # what run.outputs will hold
83
+
84
+ run = lodol.workflows.run(workflow, inputs={"client": "Acme", "month": "2026-05"})
85
+ print(run.id, run.status) # "683b...", "queued"
86
+ ```
87
+
88
+ Without `wait=True`, `run` returns as soon as the run is queued. A run started while the workspace already has as many going as its plan allows comes back `queued` too, and starts when one of them finishes. A workflow's `id` stays the same when it's edited, so it's safe to store.
89
+
90
+ **Inputs** are given by name and converted to each input's type, so `"21"` reaches a number input as `21`, and an optional input you leave out takes its default. Dates, times and `Decimal`s are sent as text Lodol reads (`date(2026, 5, 1)` becomes `"2026-05-01"`). An input the workflow doesn't declare, a missing required one, or a value of the wrong type raises `BadRequestError` naming the input, and nothing runs.
91
+
92
+ **Running something exactly once.** A retried request never starts a second run. To make sure a run happens once even across separate attempts, such as a nightly job that may be restarted, pass the same `idempotency_key` each time:
93
+
94
+ ```python
95
+ lodol.workflows.run(
96
+ "665f...",
97
+ inputs={"client": "Acme", "month": "2026-05"},
98
+ idempotency_key="monthly-report:acme:2026-05",
99
+ )
100
+ ```
101
+
102
+ Within 24 hours, the same key with the same inputs returns the run it already started, even if the first attempt is still under way. The same key with different inputs raises `UnprocessableEntityError`. Any text up to 256 characters works as a key.
103
+
104
+ **Workflows that start themselves.** A workflow that starts from its own trigger or schedule, which `workflow.starts_from_trigger` says, doesn't run to an end: running it sets it listening, and each event it catches starts a run of its own, which `runs.list` lists. The listening run has `run.is_trigger_listener` set, and its status reads `"running"` for as long as it listens. Waiting on it, with `wait=True` or `runs.wait`, returns it at once, with a warning, since there's no end to wait for.
105
+
106
+ ## Wait for a run
107
+
108
+ ```python
109
+ from lodol import RunFailedError, WaitTimeoutError
110
+
111
+ try:
112
+ run = lodol.runs.wait("683b...", timeout=600)
113
+ except RunFailedError as error:
114
+ print("It didn't work:", error.run.error)
115
+ except WaitTimeoutError:
116
+ print("Still going after 10 minutes. It carries on in Lodol.")
117
+ else:
118
+ print(run.outputs)
119
+ for step in run.steps or []:
120
+ print(step.index, step.status, step.result)
121
+ ```
122
+
123
+ `wait` checks on the run often at first, then less often, at most every 10 seconds, and never faster than your plan's API allowance (the Free plan's 5 requests a minute means a check every 12 seconds). Rate limits and brief outages don't end a wait: it slows down and keeps going, and gives up only when `timeout` passes, or after five minutes without a successful check.
124
+
125
+ The finished run comes with its outputs (`run.outputs`) and its step results (`run.steps`). An output the run never set is `None`. `RunFailedError` carries the run too, as `error.run`, so you can see which step failed. If the run has finished but its step results can't be fetched in time, you get the run without them rather than an error. Without a `timeout`, `wait` waits as long as the run takes, including while it waits for someone to answer an approval.
126
+
127
+ Each step's `status` is one of `StepStatus`: `"completed"`, `"failed"`, `"continued"` (it failed, but its setting let the run carry on; `step.error` says what went wrong) or `"skipped"` (it's turned off, so it has no result, and `step.reason` says so). A step that retried says how many tries it took in `step.attempts`.
128
+
129
+ ## Retry a failed run
130
+
131
+ ```python
132
+ from lodol import RunFailedError
133
+
134
+ try:
135
+ run = lodol.workflows.run("5c3d...", wait=True)
136
+ except RunFailedError as error:
137
+ print(error.run.error) # "The accounting system didn't answer"
138
+ # Once it's back, pick up at the step that failed:
139
+ run = lodol.runs.retry(error.run, wait=True)
140
+
141
+ print(run.outputs) # {"synced": 12}
142
+ ```
143
+
144
+ A retry is a new run, with the data the failed one started with (its inputs, or the event its trigger caught), and its `retry_of` is the failed run's `id`. By default it picks up at the step that failed, without running the steps before it again, so what they did (an email sent, a row added) isn't done twice. If the workflow has been edited since, the retry runs it as it is now when the steps before the one that failed are unchanged, so a fix to that step is picked up. `from_start=True` runs the whole workflow again instead. `wait`, `timeout` and `idempotency_key` work as they do for `workflows.run`.
145
+
146
+ `runs.get(run).retry_options` says which retries a failed run offers. A run that didn't fail can't be retried, nor can one a run calling its workflow started (retry that run instead), and one that failed before Lodol kept where runs fail can only be retried from the start. Asking for a retry a run doesn't offer raises `ConflictError` with `code` `"run_not_retryable"`.
147
+
148
+ ## Follow runs
149
+
150
+ ```python
151
+ for run in lodol.runs.list(workflow="665f...", limit=20):
152
+ print(run.created_at, run.status)
153
+ ```
154
+
155
+ Runs come newest first. Loop over the result and pages are fetched as you go, so stopping early costs nothing extra. Leave out `limit` to go through all of them, and `workflow` to see every workflow's runs. Listed runs leave out `outputs` and `steps`; `runs.get` has them. A failed run says why in `run.error`, listed or not.
156
+
157
+ ```python
158
+ from datetime import date
159
+
160
+ for run in lodol.runs.list(status="failed", started_from=date(2026, 10, 1)):
161
+ print(run.started_at, run.workflow_name, run.error)
162
+ ```
163
+
164
+ `status` takes one status or several (`status=["failed", "stopped"]`). `started_from` and `started_to` bound when runs started, `started_to` itself left out: a `date` is the start of that day, UTC, and a `datetime` without a timezone is taken as UTC. `search` finds runs by their workflow's name or a value in their data, such as a client's name in their inputs. A status runs can't be listed by, such as a typo, raises `ValueError` before anything is sent, rather than quietly listing every run.
165
+
166
+ ```python
167
+ run = lodol.runs.get("683b...", include_steps=True)
168
+ lodol.runs.stop(run)
169
+ ```
170
+
171
+ `run.workflow_id` is the workflow's `id`. `run.version_id` is the version of the workflow that ran, which changes as the workflow is edited. Times are timezone-aware `datetime`s.
172
+
173
+ Stopping a run doesn't undo the steps it already finished. `stop` returns the run, as `runs.get` would, with status `"stopping"` while it finishes the step it's on, or `"stopped"`. If the run had already finished, `stop` returns it as it finished rather than raising.
174
+
175
+ ```python
176
+ run = lodol.runs.get("683b...")
177
+ lodol.runs.stop(run)
178
+ lodol.runs.delete(run)
179
+ ```
180
+
181
+ Deleting a run removes it from the history, as deleting it in Lodol does, along with the runs it started: its trigger's, or the workflows it called. It can't be undone. A run still going has to be stopped first, or deleting it raises `ConflictError` with `code` `"run_not_finished"`. It needs a key with the **Delete runs** scope, which isn't ticked by default, made by a member who's allowed to delete runs. Like a workflow delete, it's never sent twice.
182
+
183
+ ## Answer approvals
184
+
185
+ A workflow's **Request approval** step pauses its run until someone answers. Your own system can answer instead of a person in Lodol:
186
+
187
+ ```python
188
+ run = lodol.workflows.run("4e8d...")
189
+
190
+ approval = lodol.approvals.wait_for(run) # until the run asks
191
+ if approval is not None:
192
+ print(approval.title) # "Pay invoice #4471?"
193
+ lodol.approvals.approve(approval, comment="Matches the purchase order")
194
+
195
+ run = lodol.runs.wait(run)
196
+ print(run.outputs) # {"paid": True}
197
+ ```
198
+
199
+ `wait_for` waits until the run asks for an answer and returns the request it's waiting on, or `None` if the run succeeds without asking. It paces itself as `runs.wait` does, raises `RunFailedError` if the run fails or is stopped first, and takes a `timeout` too. A run can ask more than once: call `wait_for` again after each answer. A workflow that starts from its own trigger never asks itself, since the runs its events start do, so `wait_for` returns `None` for it at once, with a warning.
200
+
201
+ To answer whatever is waiting, from any run:
202
+
203
+ ```python
204
+ for approval in lodol.approvals.list(): # pending ones, newest first
205
+ print(approval.title, approval.details, approval.workflow_name)
206
+
207
+ if approval.kind == "reply":
208
+ lodol.approvals.reply(approval, "The PO number is 4471")
209
+ else:
210
+ lodol.approvals.approve(approval, comment="Matches the purchase order")
211
+ # or lodol.approvals.reject(approval, comment="Wrong amount")
212
+ ```
213
+
214
+ The run carries on as soon as it's answered. An answer sent with an API key is recorded as that key's. `approvals.list(status="all")` includes answered requests, with `approval.decision` saying who answered, when and how. `approvals.list(run=...)` lists one run's requests, and `workflow=` one workflow's.
215
+
216
+ A request that's no longer pending can't be answered: you get `ConflictError`, whose `code` says what happened to it. `"approval_already_answered"`: someone answered it first. `"approval_expired"`: nobody answered in time, so it closed, or the step's time limit decided it automatically. `"approval_withdrawn"`: the run was stopped, or its workflow deleted, before anyone answered.
217
+
218
+ ## Workflows
219
+
220
+ ```python
221
+ workflows = lodol.workflows.list()
222
+ drafts = [workflow for workflow in workflows if workflow.is_draft]
223
+
224
+ deleted = lodol.workflows.delete("665f...")
225
+ ```
226
+
227
+ Deleting removes the workflow and every version of it, as deleting it in Lodol does: runs in progress stop, and its trigger or schedule stops listening. It can't be undone. Its past runs stay readable. It needs a key with the **Delete workflows** scope, which isn't ticked by default, made by a member who's allowed to delete workflows.
228
+
229
+ A delete is never sent twice. If it times out, check with `lodol.workflows.get(...)` before trying again, since it may have happened.
230
+
231
+ ## Create and change workflows
232
+
233
+ ```python
234
+ program = {
235
+ "kind": "Program",
236
+ "version": 1,
237
+ "body": [
238
+ {
239
+ "kind": "ExprStmt",
240
+ "expr": {
241
+ "kind": "DisplayBuiltIn",
242
+ "value": {"kind": "Literal", "value": "Hello from Lodol"},
243
+ },
244
+ }
245
+ ],
246
+ }
247
+
248
+ check = lodol.workflows.validate(program) # saves nothing
249
+ if not check.valid:
250
+ raise ValueError(check.error)
251
+
252
+ workflow = lodol.workflows.create("Say hello", program, description="A first workflow")
253
+ print(workflow.url) # where to open it in Lodol
254
+
255
+ workflow = lodol.workflows.edit(
256
+ workflow,
257
+ [{"op": "replace", "path": "/body/0/expr/value/value", "value": "Hello again"}],
258
+ )
259
+ workflow = lodol.workflows.update(workflow, name="Say hello again")
260
+ ```
261
+
262
+ A workflow's steps are its `program`, the same structure `workflows.get(...).program` gives you. `validate` checks one without saving it: `check.error` says what to fix, and for a valid program, `check.warnings` names likely mistakes. It checks the program, not what the key's member may use (connections, and the workflows it calls), so a program that passes can still be refused when saved. `create` saves a new workflow, published, and private to the key's member until they share it in Lodol. It doesn't run it. A program Lodol can't save raises `BadRequestError` with `code` `"invalid_workflow"`, and nothing is saved.
263
+
264
+ `update` replaces a workflow's `program`, `name` or `description`, leaving the rest as it is, and `edit` changes its program with JSON Patch operations (`"replace"`, `"add"` or `"remove"`), applied in order to the program as it is now. Each is saved as an edit in Lodol is: as a new version, or, when the workflow was saved within the last few minutes, over that version; `workflow.version_id` names the version saved. Saving pauses the trigger of a workflow that was listening, and `workflow.trigger_paused` says so: run it again to set it listening on the new version. After any save, `workflow.apps_to_connect` names the apps it uses that the key's member has yet to connect in Lodol, which it can't run without.
265
+
266
+ These need a key with the **Create and edit workflows** scope (`workflows:write`), which is ticked by default, made by a member allowed to create or edit workflows. A save is never made twice: a retried request gets back the workflow the first one saved.
267
+
268
+ ## Errors
269
+
270
+ When Lodol or the network says no, the SDK raises a `LodolError`. Errors from the API carry Lodol's own explanation as their message, and `code`, a stable name for what went wrong (such as `"run_already_finished"`): decide what to do from `code`, since the wording may change. `request_id` names the request; it's also in the error's text, so quote that when you contact Lodol's support. A call that can't be made as written (inputs that aren't a dict, a timeout that isn't a number) raises `TypeError` or `ValueError` before anything is sent.
271
+
272
+ | Exception | When | What to do |
273
+ | --- | --- | --- |
274
+ | `RunFailedError` | A run you waited for failed or was stopped | Look at `error.run` |
275
+ | `WaitTimeoutError` | A run wasn't finished within `timeout` | Wait again, or stop it. The run carries on |
276
+ | `BadRequestError` | The request was refused, such as inputs that don't fit | Read the message: it names the problem |
277
+ | `AuthenticationError` | The key is missing, wrong, revoked or expired | Check `LODOL_API_KEY` |
278
+ | `PermissionDeniedError` | The key's scopes, or its creator's role, don't allow this | Give the key the scope, or use another key |
279
+ | `NotFoundError` | No such workflow, run or approval in this workspace | Check the id |
280
+ | `ConflictError` | An approval was already answered, expired or was withdrawn (`code` says which); or Lodol was still working on an earlier attempt of a request after five minutes | For an approval, nothing. Otherwise check before trying again: the message has the key to retry with |
281
+ | `RunLimitError` | This month's runs are used up | Runs come back at `error.resets_at` |
282
+ | `RateLimitError` | Too many requests, for longer than the SDK waits on its own | Wait `error.retry_after` seconds |
283
+ | `APIConnectionError`, `APITimeoutError` | Lodol couldn't be reached, didn't answer, or had a certificate that couldn't be verified | Try again later; for a certificate, see the message |
284
+
285
+ ## What happens automatically
286
+
287
+ - **Retries.** A request that fails for a passing reason (a dropped connection, a server error, a rate limit that clears within a minute) is sent again, up to `max_retries` times, with growing pauses. Only requests that can't take effect twice are retried: reads, and checking a workflow; runs, run retries, stops, approval answers and workflow saves, which carry an idempotency key; and anything a rate limit refused, since Lodol refuses before doing anything. A delete that fails otherwise, and limits that last longer, are reported at once.
288
+ - **Idempotency keys.** Every run, run retry, stop, approval answer and workflow save gets one, so a retry can never start a second run, answer twice or save twice. If a retry finds Lodol still working on the first attempt, it waits for that, up to five minutes, rather than starting again.
289
+ - **Timeouts.** Each request may take 30 seconds (`timeout`). Waiting for a run has its own `timeout`.
290
+ - **Security.** Your key is only sent over HTTPS, except to Lodol Local on your own machine, which is reached directly, never through a proxy. It never appears in logs or reprs.
291
+
292
+ ## Async
293
+
294
+ ```python
295
+ from lodol import AsyncLodol
296
+
297
+ async with AsyncLodol() as lodol:
298
+ run = await lodol.workflows.run("665f...", inputs={"client": "Acme"}, wait=True)
299
+
300
+ async for approval in lodol.approvals.list():
301
+ print(approval.title)
302
+ ```
303
+
304
+ `AsyncLodol` does everything `Lodol` does, with every call awaited and lists looped over with `async for`. It works with asyncio and trio.
305
+
306
+ ## Lodol Local
307
+
308
+ Point the client at your own installation:
309
+
310
+ ```python
311
+ from lodol import Lodol
312
+
313
+ lodol = Lodol(base_url="http://localhost:3901")
314
+ ```
315
+
316
+ or set `LODOL_BASE_URL`. Plain `http://` is allowed only for this machine (`localhost`, or `host.docker.internal` from a container); anything else must be `https://`.
317
+
318
+ ## Settings
319
+
320
+ | Argument | Environment variable | Default |
321
+ | --- | --- | --- |
322
+ | `api_key` | `LODOL_API_KEY` | (required) |
323
+ | `base_url` | `LODOL_BASE_URL` | `https://api-prod.lodol.com/api/v1` |
324
+ | `timeout` | | `30` seconds per request |
325
+ | `max_retries` | | `2` |
326
+ | `http_client` | | Your own `httpx2.Client` (or `httpx2.AsyncClient`) for proxies, custom certificates or tests |
327
+
328
+ Proxies and custom certificates are also picked up from the usual environment variables (`HTTPS_PROXY`, `SSL_CERT_FILE`).
329
+
330
+ ## Logging
331
+
332
+ Set `LODOL_LOG=info` to see retries and what runs you're waiting for are doing, or `LODOL_LOG=debug` to see every request too. Or configure the `lodol` logger with Python's `logging`. Requests are logged by method, path and status only, never with your key or the inputs you send.
333
+
334
+ ## Testing your code
335
+
336
+ Give the client an `httpx2` mock transport and answer as Lodol would:
337
+
338
+ ```python
339
+ import httpx2
340
+ from lodol import Lodol
341
+
342
+
343
+ def fake_lodol(request: httpx2.Request) -> httpx2.Response:
344
+ if request.url.path.endswith("/run-async"):
345
+ return httpx2.Response(202, json={"execution_id": "683b", "status": "queued"})
346
+ return httpx2.Response(404, json={"error": "Workflow not found"})
347
+
348
+
349
+ lodol = Lodol(
350
+ api_key="sk_live_test",
351
+ http_client=httpx2.Client(transport=httpx2.MockTransport(fake_lodol)),
352
+ )
353
+ ```
354
+
355
+ Models are plain data, so you can also build them directly: `Run(id="683b", status="success")`. They can be compared, hashed and pickled, and so can the SDK's errors.
356
+
357
+ ## Endpoints the SDK doesn't cover yet
358
+
359
+ ```python
360
+ body = lodol.request("GET", "/approvals", params={"status": "all"})
361
+ ```
362
+
363
+ `request` reaches any Developer API endpoint and returns its JSON. Errors raise as they do everywhere else. A write is retried only when you give it an `idempotency_key`.
364
+
365
+ ## Upgrading from 0.1
366
+
367
+ 0.2 renames executions to runs, to match Lodol, and changes a few behaviors. See [CHANGELOG.md](CHANGELOG.md) for the full list.